Web + backend: propagate tag_mode (AND/OR) end-to-end; AND-mode overlap prioritization for creatures and theme spells; headless configs support tag_mode; add Scryfall attribution footer and configs UI indicators; minor polish. (#and-overlap-pass)

This commit is contained in:
mwisnowski 2025-08-26 11:34:42 -07:00
parent 0f73a85a4e
commit fd7fc01071
15 changed files with 1339 additions and 75 deletions

View file

@ -281,6 +281,8 @@ class DeckBuilder(
secondary_tag: Optional[str] = None secondary_tag: Optional[str] = None
tertiary_tag: Optional[str] = None tertiary_tag: Optional[str] = None
selected_tags: List[str] = field(default_factory=list) selected_tags: List[str] = field(default_factory=list)
# How to combine multiple selected tags when prioritizing cards: 'AND' or 'OR'
tag_mode: str = 'AND'
# Future deck config placeholders # Future deck config placeholders
color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G'] color_identity: List[str] = field(default_factory=list) # raw list of color letters e.g. ['B','G']

View file

@ -85,6 +85,8 @@ class CreatureAdditionMixin:
creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell) creature_df['_parsedThemeTags'] = creature_df['themeTags'].apply(bu.normalize_tag_cell)
creature_df['_normTags'] = creature_df['_parsedThemeTags'] creature_df['_normTags'] = creature_df['_parsedThemeTags']
creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst)) creature_df['_multiMatch'] = creature_df['_normTags'].apply(lambda lst: sum(1 for t in selected_tags_lower if t in lst))
# In AND mode, prefer intersections: create a hard filter order 3 -> 2 -> 1 matches
combine_mode = getattr(self, 'tag_mode', 'AND')
base_top = 30 base_top = 30
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0)) top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2) synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
@ -104,6 +106,10 @@ class CreatureAdditionMixin:
continue continue
tnorm = tag.lower() tnorm = tag.lower()
subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))] subset = creature_df[creature_df['_normTags'].apply(lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst))]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
# Constrain to multi-tag overlap first if available
if (creature_df['_multiMatch'] >= 2).any():
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty: if subset.empty:
self.output_func(f"Theme '{tag}' produced no creature candidates.") self.output_func(f"Theme '{tag}' produced no creature candidates.")
continue continue
@ -115,7 +121,11 @@ class CreatureAdditionMixin:
pool = pool[~pool['name'].isin(added_names)] pool = pool[~pool['name'].isin(added_names)]
if pool.empty: if pool.empty:
continue continue
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])] # In AND mode, boost weights more aggressively for 2+ tag matches
if combine_mode == 'AND':
weighted_pool = [(nm, (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
else:
weighted_pool = [(nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch'])]
chosen = bu.weighted_sample_without_replacement(weighted_pool, target) chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen: for nm in chosen:
if commander_name and nm == commander_name: if commander_name and nm == commander_name:
@ -145,7 +155,14 @@ class CreatureAdditionMixin:
if total_added < desired_total: if total_added < desired_total:
need = desired_total - total_added need = desired_total - total_added
multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy() multi_pool = creature_df[~creature_df['name'].isin(added_names)].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0] if combine_mode == 'AND' and len(selected_tags_lower) > 1:
# First prefer 3+ then 2, finally 1
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
if prioritized.empty:
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
multi_pool = prioritized
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty: if not multi_pool.empty:
if 'edhrecRank' in multi_pool.columns: if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last') multi_pool = multi_pool.sort_values(by=['_multiMatch','edhrecRank','manaValue'], ascending=[False, True, True], na_position='last')

View file

@ -452,6 +452,7 @@ class SpellAdditionMixin:
spells_df['_multiMatch'] = spells_df['_normTags'].apply( spells_df['_multiMatch'] = spells_df['_normTags'].apply(
lambda lst: sum(1 for t in selected_tags_lower if t in lst) lambda lst: sum(1 for t in selected_tags_lower if t in lst)
) )
combine_mode = getattr(self, 'tag_mode', 'AND')
base_top = 40 base_top = 40
top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0)) top_n = int(base_top * getattr(bc, 'THEME_POOL_SIZE_MULTIPLIER', 2.0))
synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2) synergy_bonus = getattr(bc, 'THEME_PRIORITY_BONUS', 1.2)
@ -473,6 +474,9 @@ class SpellAdditionMixin:
lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst) lambda lst, tn=tnorm: (tn in lst) or any(tn in x for x in lst)
) )
] ]
if combine_mode == 'AND' and len(selected_tags_lower) > 1:
if (spells_df['_multiMatch'] >= 2).any():
subset = subset[subset['_multiMatch'] >= 2]
if subset.empty: if subset.empty:
continue continue
if 'edhrecRank' in subset.columns: if 'edhrecRank' in subset.columns:
@ -491,7 +495,10 @@ class SpellAdditionMixin:
pool = pool[~pool['name'].isin(self.card_library.keys())] pool = pool[~pool['name'].isin(self.card_library.keys())]
if pool.empty: if pool.empty:
continue continue
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ] if combine_mode == 'AND':
weighted_pool = [ (nm, (synergy_bonus*1.3 if mm >= 2 else (1.1 if mm == 1 else 0.8))) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
else:
weighted_pool = [ (nm, (synergy_bonus if mm >= 2 else 1.0)) for nm, mm in zip(pool['name'], pool['_multiMatch']) ]
chosen = bu.weighted_sample_without_replacement(weighted_pool, target) chosen = bu.weighted_sample_without_replacement(weighted_pool, target)
for nm in chosen: for nm in chosen:
row = pool[pool['name'] == nm].iloc[0] row = pool[pool['name'] == nm].iloc[0]
@ -514,7 +521,13 @@ class SpellAdditionMixin:
if total_added < remaining: if total_added < remaining:
need = remaining - total_added need = remaining - total_added
multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy() multi_pool = spells_df[~spells_df['name'].isin(self.card_library.keys())].copy()
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0] if combine_mode == 'AND' and len(selected_tags_lower) > 1:
prioritized = multi_pool[multi_pool['_multiMatch'] >= 2]
if prioritized.empty:
prioritized = multi_pool[multi_pool['_multiMatch'] > 0]
multi_pool = prioritized
else:
multi_pool = multi_pool[multi_pool['_multiMatch'] > 0]
if not multi_pool.empty: if not multi_pool.empty:
if 'edhrecRank' in multi_pool.columns: if 'edhrecRank' in multi_pool.columns:
multi_pool = multi_pool.sort_values( multi_pool = multi_pool.sort_values(

View file

@ -639,6 +639,7 @@ class ReportingMixin:
"secondary_tag": getattr(self, 'secondary_tag', None), "secondary_tag": getattr(self, 'secondary_tag', None),
"tertiary_tag": getattr(self, 'tertiary_tag', None), "tertiary_tag": getattr(self, 'tertiary_tag', None),
"bracket_level": getattr(self, 'bracket_level', None), "bracket_level": getattr(self, 'bracket_level', None),
"tag_mode": (getattr(self, 'tag_mode', 'AND') or 'AND'),
"use_multi_theme": True, "use_multi_theme": True,
"add_lands": True, "add_lands": True,
"add_creatures": True, "add_creatures": True,

View file

@ -4501,7 +4501,7 @@ def tag_for_bending(df: pd.DataFrame, color: str) -> None:
rules = [] rules = []
air_mask = tag_utils.create_text_mask(df, 'airbend') air_mask = tag_utils.create_text_mask(df, 'airbend')
if air_mask.any(): if air_mask.any():
rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters'] }) rules.append({ 'mask': air_mask, 'tags': ['Airbending', 'Exile Matters', 'Leave the Battlefield'] })
water_mask = tag_utils.create_text_mask(df, 'waterbend') water_mask = tag_utils.create_text_mask(df, 'waterbend')
if water_mask.any(): if water_mask.any():

View file

@ -28,7 +28,12 @@ async def build_step1(request: Request) -> HTMLResponse:
@router.post("/step1", response_class=HTMLResponse) @router.post("/step1", response_class=HTMLResponse)
async def build_step1_search(request: Request, query: str = Form(""), auto: str | None = Form(None)) -> HTMLResponse: async def build_step1_search(
request: Request,
query: str = Form(""),
auto: str | None = Form(None),
active: str | None = Form(None),
) -> HTMLResponse:
query = (query or "").strip() query = (query or "").strip()
auto_enabled = True if (auto == "1") else False auto_enabled = True if (auto == "1") else False
candidates = [] candidates = []
@ -45,10 +50,22 @@ async def build_step1_search(request: Request, query: str = Form(""), auto: str
"request": request, "request": request,
"commander": res, "commander": res,
"tags": orch.tags_for_commander(res["name"]), "tags": orch.tags_for_commander(res["name"]),
"recommended": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
}, },
) )
return templates.TemplateResponse("build/_step1.html", {"request": request, "query": query, "candidates": candidates, "auto": auto_enabled}) return templates.TemplateResponse(
"build/_step1.html",
{
"request": request,
"query": query,
"candidates": candidates,
"auto": auto_enabled,
"active": active,
"count": len(candidates) if candidates else 0,
},
)
@router.post("/step1/inspect", response_class=HTMLResponse) @router.post("/step1/inspect", response_class=HTMLResponse)
@ -72,6 +89,8 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
"request": request, "request": request,
"commander": res, "commander": res,
"tags": orch.tags_for_commander(res["name"]), "tags": orch.tags_for_commander(res["name"]),
"recommended": orch.recommended_tags_for_commander(res["name"]),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
}, },
) )
@ -93,11 +112,14 @@ async def build_step2_get(request: Request) -> HTMLResponse:
"request": request, "request": request,
"commander": {"name": commander}, "commander": {"name": commander},
"tags": tags, "tags": tags,
"recommended": orch.recommended_tags_for_commander(commander),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
"primary_tag": selected[0] if len(selected) > 0 else "", "primary_tag": selected[0] if len(selected) > 0 else "",
"secondary_tag": selected[1] if len(selected) > 1 else "", "secondary_tag": selected[1] if len(selected) > 1 else "",
"tertiary_tag": selected[2] if len(selected) > 2 else "", "tertiary_tag": selected[2] if len(selected) > 2 else "",
"selected_bracket": sess.get("bracket"), "selected_bracket": sess.get("bracket"),
"tag_mode": sess.get("tag_mode", "AND"),
}, },
) )
@ -109,6 +131,7 @@ async def build_step2_submit(
primary_tag: str | None = Form(None), primary_tag: str | None = Form(None),
secondary_tag: str | None = Form(None), secondary_tag: str | None = Form(None),
tertiary_tag: str | None = Form(None), tertiary_tag: str | None = Form(None),
tag_mode: str | None = Form("AND"),
bracket: int = Form(...), bracket: int = Form(...),
) -> HTMLResponse: ) -> HTMLResponse:
# Validate primary tag selection if tags are available # Validate primary tag selection if tags are available
@ -120,12 +143,15 @@ async def build_step2_submit(
"request": request, "request": request,
"commander": {"name": commander}, "commander": {"name": commander},
"tags": available_tags, "tags": available_tags,
"recommended": orch.recommended_tags_for_commander(commander),
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
"error": "Please choose a primary theme.", "error": "Please choose a primary theme.",
"primary_tag": primary_tag or "", "primary_tag": primary_tag or "",
"secondary_tag": secondary_tag or "", "secondary_tag": secondary_tag or "",
"tertiary_tag": tertiary_tag or "", "tertiary_tag": tertiary_tag or "",
"selected_bracket": int(bracket) if bracket is not None else None, "selected_bracket": int(bracket) if bracket is not None else None,
"tag_mode": (tag_mode or "AND"),
}, },
) )
@ -134,6 +160,7 @@ async def build_step2_submit(
sess = get_session(sid) sess = get_session(sid)
sess["commander"] = commander sess["commander"] = commander
sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t] sess["tags"] = [t for t in [primary_tag, secondary_tag, tertiary_tag] if t]
sess["tag_mode"] = (tag_mode or "AND").upper()
sess["bracket"] = int(bracket) sess["bracket"] = int(bracket)
# Proceed to Step 3 placeholder for now # Proceed to Step 3 placeholder for now
return templates.TemplateResponse( return templates.TemplateResponse(
@ -309,6 +336,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
tags=sess.get("tags", []), tags=sess.get("tags", []),
bracket=safe_bracket, bracket=safe_bracket,
ideals=ideals_val, ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
) )
res = orch.run_stage(sess["build_ctx"], rerun=False) res = orch.run_stage(sess["build_ctx"], rerun=False)
status = "Build complete" if res.get("done") else "Stage complete" status = "Build complete" if res.get("done") else "Stage complete"
@ -367,6 +395,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
tags=sess.get("tags", []), tags=sess.get("tags", []),
bracket=safe_bracket, bracket=safe_bracket,
ideals=ideals_val, ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
) )
res = orch.run_stage(sess["build_ctx"], rerun=True) res = orch.run_stage(sess["build_ctx"], rerun=True)
status = "Stage rerun complete" if not res.get("done") else "Build complete" status = "Stage rerun complete" if not res.get("done") else "Build complete"
@ -430,6 +459,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
tags=sess.get("tags", []), tags=sess.get("tags", []),
bracket=safe_bracket, bracket=safe_bracket,
ideals=ideals_val, ideals=ideals_val,
tag_mode=sess.get("tag_mode", "AND"),
) )
res = orch.run_stage(sess["build_ctx"], rerun=False) res = orch.run_stage(sess["build_ctx"], rerun=False)
status = "Stage complete" if not res.get("done") else "Build complete" status = "Stage complete" if not res.get("done") else "Build complete"

View file

@ -117,9 +117,16 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t] tags = [t for t in [cfg.get("primary_tag"), cfg.get("secondary_tag"), cfg.get("tertiary_tag")] if t]
bracket = int(cfg.get("bracket_level") or 0) bracket = int(cfg.get("bracket_level") or 0)
ideals = cfg.get("ideal_counts", {}) or {} ideals = cfg.get("ideal_counts", {}) or {}
# Optional combine mode for tags (AND/OR); support a few aliases
try:
tag_mode = (str(cfg.get("tag_mode") or cfg.get("combine_mode") or cfg.get("mode") or "AND").upper())
if tag_mode not in ("AND", "OR"):
tag_mode = "AND"
except Exception:
tag_mode = "AND"
# Run build headlessly with orchestrator # Run build headlessly with orchestrator
res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals) res = orch.run_build(commander=commander, tags=tags, bracket=bracket, ideals=ideals, tag_mode=tag_mode)
if not res.get("ok"): if not res.get("ok"):
return templates.TemplateResponse( return templates.TemplateResponse(
"configs/run_result.html", "configs/run_result.html",
@ -130,6 +137,7 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"log": res.get("log", ""), "log": res.get("log", ""),
"cfg_name": p.name, "cfg_name": p.name,
"commander": commander, "commander": commander,
"tag_mode": tag_mode,
}, },
) )
return templates.TemplateResponse( return templates.TemplateResponse(
@ -143,6 +151,7 @@ async def configs_run(request: Request, name: str = Form(...)) -> HTMLResponse:
"summary": res.get("summary"), "summary": res.get("summary"),
"cfg_name": p.name, "cfg_name": p.name,
"commander": commander, "commander": commander,
"tag_mode": tag_mode,
"game_changers": bc.GAME_CHANGERS, "game_changers": bc.GAME_CHANGERS,
}, },
) )

View file

@ -10,6 +10,8 @@ import time
import json import json
from datetime import datetime as _dt from datetime import datetime as _dt
import re import re
import unicodedata
from glob import glob
def commander_names() -> List[str]: def commander_names() -> List[str]:
@ -19,6 +21,22 @@ def commander_names() -> List[str]:
def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, List[str]]]: def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, List[str]]]:
def _strip_accents(s: str) -> str:
try:
return ''.join(ch for ch in unicodedata.normalize('NFKD', str(s)) if not unicodedata.combining(ch))
except Exception:
return str(s)
def _simplify(s: str) -> str:
try:
s2 = _strip_accents(str(s))
s2 = s2.lower()
# remove punctuation/symbols, keep letters/numbers/spaces
s2 = re.sub(r"[^a-z0-9\s]", " ", s2)
s2 = re.sub(r"\s+", " ", s2).strip()
return s2
except Exception:
return str(s).lower().strip()
# Normalize query similar to CLI to reduce case sensitivity surprises # Normalize query similar to CLI to reduce case sensitivity surprises
tmp = DeckBuilder() tmp = DeckBuilder()
try: try:
@ -64,15 +82,21 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li
# to avoid missing obvious matches like 'Inti, Seneschal of the Sun' for 'inti'. # to avoid missing obvious matches like 'Inti, Seneschal of the Sun' for 'inti'.
try: try:
q_raw = (query or "").strip().lower() q_raw = (query or "").strip().lower()
q_norm = _simplify(query)
if q_raw: if q_raw:
have = {n for (n, _s) in pool} have = {n for (n, _s) in pool}
# Map original scores for reuse # Map original scores for reuse
base_scores = {n: int(s) for (n, s) in scored_raw} base_scores = {n: int(s) for (n, s) in scored_raw}
for n in names: for n in names:
nl = str(n).lower() nl = str(n).lower()
if q_raw in nl and n not in have: nn = _simplify(n)
if (q_raw in nl or (q_norm and q_norm in nn)) and n not in have:
# Assign a reasonable base score if not present; favor prefixes # Assign a reasonable base score if not present; favor prefixes
approx = base_scores.get(n, 90 if nl.startswith(q_raw) else 80) approx_base = base_scores.get(n)
if approx_base is None:
starts = nl.startswith(q_raw) or (q_norm and nn.startswith(q_norm))
approx_base = 90 if starts else 80
approx = approx_base
pool.append((n, approx)) pool.append((n, approx))
except Exception: except Exception:
pass pass
@ -82,7 +106,9 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li
except Exception: except Exception:
df = None df = None
q = (query or "").strip().lower() q = (query or "").strip().lower()
qn = _simplify(query)
tokens = [t for t in re.split(r"[\s,]+", q) if t] tokens = [t for t in re.split(r"[\s,]+", q) if t]
tokens_norm = [t for t in (qn.split(" ") if qn else []) if t]
def _color_list_for(name: str) -> List[str]: def _color_list_for(name: str) -> List[str]:
colors: List[str] = [] colors: List[str] = []
try: try:
@ -106,42 +132,60 @@ def commander_candidates(query: str, limit: int = 10) -> List[Tuple[str, int, Li
colors: List[str] = [] colors: List[str] = []
colors = _color_list_for(name) colors = _color_list_for(name)
nl = str(name).lower() nl = str(name).lower()
nnorm = _simplify(name)
bonus = 0 bonus = 0
pos = nl.find(q) if q else -1 pos = nl.find(q) if q else -1
pos_norm = nnorm.find(qn) if qn else -1
pos_final = pos if pos >= 0 else pos_norm
# Extract first word (letters only) for exact first-word preference # Extract first word (letters only) for exact first-word preference
try: try:
m_first = re.match(r"^[a-z0-9']+", nl) m_first = re.match(r"^[a-z0-9']+", nl)
first_word = m_first.group(0) if m_first else "" first_word = m_first.group(0) if m_first else ""
except Exception: except Exception:
first_word = nl.split(" ", 1)[0] if nl else "" first_word = nl.split(" ", 1)[0] if nl else ""
exact_first = 1 if (q and first_word == q) else 0 # Normalized first word
try:
m_first_n = re.match(r"^[a-z0-9']+", nnorm)
first_word_n = m_first_n.group(0) if m_first_n else ""
except Exception:
first_word_n = nnorm.split(" ", 1)[0] if nnorm else ""
exact_first = 1 if ((q and first_word == q) or (qn and first_word_n == qn)) else 0
# Base heuristics # Base heuristics
if q: if q or qn:
if nl == q: if q and nl == q:
bonus += 100 bonus += 100
if nl.startswith(q): elif qn and nnorm == qn:
bonus += 85
if (q and nl.startswith(q)) or (qn and nnorm.startswith(qn)):
bonus += 60 bonus += 60
if re.search(r"\b" + re.escape(q), nl): if q and re.search(r"\b" + re.escape(q), nl):
bonus += 40 bonus += 40
if q in nl: if (q and q in nl) or (qn and qn in nnorm):
bonus += 30 bonus += 30
# Strongly prefer exact first-word equality over general prefix # Strongly prefer exact first-word equality over general prefix
if exact_first: if exact_first:
bonus += 140 bonus += 140
# Multi-token bonuses # Multi-token bonuses
if tokens: if tokens_norm or tokens:
present = sum(1 for t in tokens if t in nl) present = 0
all_present = 1 if all(t in nl for t in tokens) else 0 all_present = 0
if tokens_norm:
present = sum(1 for t in tokens_norm if t in nnorm)
all_present = 1 if all(t in nnorm for t in tokens_norm) else 0
elif tokens:
present = sum(1 for t in tokens if t in nl)
all_present = 1 if all(t in nl for t in tokens) else 0
bonus += present * 10 + all_present * 40 bonus += present * 10 + all_present * 40
# Extra if first token is a prefix # Extra if first token is a prefix
if nl.startswith(tokens[0]): t0 = (tokens_norm[0] if tokens_norm else (tokens[0] if tokens else None))
if t0 and (nnorm.startswith(t0) or nl.startswith(t0)):
bonus += 15 bonus += 15
# Favor shorter names slightly and earlier positions # Favor shorter names slightly and earlier positions
bonus += max(0, 20 - len(nl)) bonus += max(0, 20 - len(nl))
if pos >= 0: if pos_final >= 0:
bonus += max(0, 20 - pos) bonus += max(0, 20 - pos_final)
rank_score = int(score) + bonus rank_score = int(score) + bonus
rescored.append((name, int(score), colors, rank_score, pos if pos >= 0 else 10**6, exact_first)) rescored.append((name, int(score), colors, rank_score, pos_final if pos_final >= 0 else 10**6, exact_first))
# Sort: exact first-word matches first, then by rank score desc, then earliest position, then original score desc, then name asc # Sort: exact first-word matches first, then by rank score desc, then earliest position, then original score desc, then name asc
rescored.sort(key=lambda x: (-x[5], -x[3], x[4], -x[1], x[0])) rescored.sort(key=lambda x: (-x[5], -x[3], x[4], -x[1], x[0]))
@ -200,6 +244,214 @@ def tags_for_commander(name: str) -> List[str]:
return [] return []
def _recommended_scored(name: str, max_items: int = 5) -> List[Tuple[str, int, List[str]]]:
"""Internal: return list of (tag, score, reasons[]) for top recommendations."""
available_list = list(tags_for_commander(name) or [])
if not available_list:
return []
# Case-insensitive map: normalized -> original display tag
def _norm(s: str) -> str:
try:
return re.sub(r"\s+", " ", str(s).strip().lower())
except Exception:
return str(s).strip().lower()
norm_map: Dict[str, str] = { _norm(t): t for t in available_list }
available_norm = set(norm_map.keys())
available_norm_list = list(available_norm)
def _best_match_norm(tn: str) -> str | None:
"""Return the best available normalized tag matching tn by exact or substring."""
if tn in available_norm:
return tn
try:
# prefer contains matches with minimal length difference
candidates = []
for an in available_norm_list:
if tn in an or an in tn:
candidates.append((abs(len(an) - len(tn)), an))
if candidates:
candidates.sort(key=lambda x: (x[0], x[1]))
return candidates[0][1]
except Exception:
return None
return None
try:
tmp = DeckBuilder()
df = tmp.load_commander_data()
except Exception:
df = None
# Gather commander text and colors
text = ""
colors: List[str] = []
if df is not None:
try:
row = df[df["name"].astype(str) == str(name)]
if not row.empty:
r0 = row.iloc[0]
text = str(r0.get("text", r0.get("oracleText", "")) or "").lower()
ci = r0.get("colorIdentity")
if isinstance(ci, list):
colors = [str(c).upper() for c in ci if str(c).strip()]
elif isinstance(ci, str) and ci.strip():
parts = [p.strip().upper() for p in ci.replace('[', '').replace(']', '').replace("'", '').split(',') if p.strip()]
colors = parts if parts else list(ci)
except Exception:
pass
if not colors:
colors = ["C"]
score: Dict[str, int] = {t: 0 for t in available_list}
reasons: Dict[str, List[str]] = {t: [] for t in available_list}
order_index = {t: i for i, t in enumerate(list(available_list))}
# Anchor weight; omit reason to keep tooltip focused
for t in list(available_list):
score[t] += 30
# Keyword patterns -> tags with labeled reasons
patterns: List[Tuple[str, List[str], List[str], int]] = [
("Oracle mentions treasure/tokens", [r"\btreasure\b"], ["treasure", "tokens"], 8),
("Oracle mentions tokens", [r"\btoken\b", r"create .* token"], ["tokens"], 10),
("Oracle mentions sacrifice/death", [r"\bsacrifice\b", r"whenever .* dies"], ["sacrifice", "aristocrats"], 9),
("Oracle mentions graveyard/recursion", [r"graveyard", r"from your graveyard", r"return .* from graveyard"], ["graveyard"], 9),
("Oracle mentions lifegain/lifelink", [r"\bgain life\b", r"lifelink"], ["lifegain"], 9),
("Oracle mentions instants/sorceries", [r"instant or sorcery", r"whenever you cast an instant", r"prowess"], ["spellslinger", "spells"], 9),
("Oracle mentions artifacts/equipment", [r"\bartifact\b", r"equipment"], ["artifacts", "equipment"], 8),
("Oracle mentions enchantments/auras", [r"\benchant\b", r"aura"], ["enchantments", "auras"], 7),
("Oracle mentions +1/+1 counters", [r"\+1/\+1 counter", r"put .* counters?"], ["+1/+1 counters", "counters"], 8),
("Oracle suggests blink/flicker", [r"exile .* return .* battlefield", r"blink"], ["blink"], 7),
("Oracle mentions vehicles/crew", [r"vehicle", r"crew"], ["vehicles"], 6),
("Oracle references legendary/legends", [r"\blegendary\b", r"legend(ary)?\b"], ["legends matter", "legends", "legendary matters"], 8),
("Oracle references historic", [r"\bhistoric(s)?\b"], ["historics matter", "historic"], 7),
("Oracle suggests aggressive attacks/haste", [r"\bhaste\b", r"attacks? each combat", r"whenever .* attacks"], ["aggro"], 6),
("Oracle references direct damage", [r"deal \d+ damage", r"damage to any target", r"noncombat damage"], ["burn"], 6),
]
for label, pats, tags_out, w in patterns:
try:
if any(re.search(p, text) for p in pats):
for tg in tags_out:
tn = _norm(tg)
bm = _best_match_norm(tn)
if bm is None:
continue
orig = norm_map[bm]
score[orig] = score.get(orig, 0) + w
if len(reasons[orig]) < 3 and label not in reasons[orig]:
reasons[orig].append(label)
except Exception:
continue
# Color identity mapped defaults
ci_key_sorted = ''.join(sorted(colors))
color_map: Dict[str, List[Tuple[str, int]]] = {
'GW': [("tokens", 5), ("enchantments", 4), ("+1/+1 counters", 4)],
'WU': [("blink", 5), ("control", 4)],
'UB': [("graveyard", 5), ("control", 4)],
'BR': [("sacrifice", 5), ("aristocrats", 4)],
'RG': [("landfall", 4), ("tokens", 3)],
'UR': [("spells", 5), ("artifacts", 4)],
'WB': [("lifegain", 5), ("aristocrats", 4)],
'BG': [("graveyard", 5), ("counters", 4)],
'WR': [("equipment", 5), ("tokens", 4)],
'UG': [("+1/+1 counters", 5), ("ramp", 4)],
'WUB': [("blink", 4), ("control", 4)],
'WBR': [("lifegain", 4), ("aristocrats", 4)],
'UBR': [("spells", 4), ("artifacts", 3)],
'BRG': [("sacrifice", 4), ("graveyard", 4)],
'RGW': [("tokens", 4), ("counters", 3)],
'GWU': [("blink", 4), ("enchantments", 3)],
'WUBR': [("control", 4), ("spells", 3)],
'UBRG': [("graveyard", 4), ("spells", 3)],
'BRGW': [("tokens", 3), ("sacrifice", 3)],
'RGWU': [("counters", 3), ("tokens", 3)],
'WUBRG': [("artifacts", 3), ("tokens", 3)],
}
# Build lookup keyed by sorted color string to be order-agnostic
try:
color_map_lookup: Dict[str, List[Tuple[str, int]]] = { ''.join(sorted(list(k))): v for k, v in color_map.items() }
except Exception:
color_map_lookup = color_map
if ci_key_sorted in color_map_lookup:
for tg, w in color_map_lookup[ci_key_sorted]:
tn = _norm(tg)
bm = _best_match_norm(tn)
if bm is None:
continue
orig = norm_map[bm]
score[orig] = score.get(orig, 0) + w
cr = f"Fits your colors ({ci_key_sorted})"
if len(reasons[orig]) < 3 and cr not in reasons[orig]:
reasons[orig].append(cr)
# Past builds history
try:
for path in glob(os.path.join('deck_files', '*.summary.json')):
try:
st = os.stat(path)
age_days = max(0, (time.time() - st.st_mtime) / 86400.0)
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f) or {}
meta = data.get('meta') or {}
if str(meta.get('commander', '')).strip() != str(name).strip():
continue
tags_list = meta.get('tags') or []
for tg in tags_list:
tn = _norm(str(tg))
if tn in available_norm:
orig = norm_map[tn]
inc = 2
recent = False
if age_days <= 30:
inc += 2
recent = True
elif age_days <= 90:
inc += 1
score[orig] = score.get(orig, 0) + inc
lbl = "Popular in your past builds" + (" (recent)" if recent else "")
if len(reasons[orig]) < 3 and lbl not in reasons[orig]:
reasons[orig].append(lbl)
except Exception:
continue
except Exception:
pass
items = [(k, score.get(k, 0), reasons.get(k, [])) for k in available_list]
items.sort(key=lambda x: (-x[1], order_index.get(x[0], 10**6), x[0]))
# Trim reasons to at most two concise bullets and format as needed later
top = items[:max_items]
return top
def recommended_tags_for_commander(name: str, max_items: int = 5) -> List[str]:
"""Suggest up to `max_items` theme tags for a commander (tags only)."""
try:
return [tag for (tag, _s, _r) in _recommended_scored(name, max_items=max_items)]
except Exception:
return []
def recommended_tag_reasons_for_commander(name: str, max_items: int = 5) -> Dict[str, str]:
"""Return a mapping of tag -> short reason for why it was recommended."""
try:
res: Dict[str, str] = {}
for tag, _score, rs in _recommended_scored(name, max_items=max_items):
# Build a concise reason string
if not rs:
res[tag] = "From this commander's theme list"
else:
# Take up to two distinct reasons
uniq: List[str] = []
for r in rs:
if r and r not in uniq:
uniq.append(r)
if len(uniq) >= 2:
break
res[tag] = "; ".join(uniq)
return res
except Exception:
return {}
def bracket_options() -> List[Dict[str, Any]]: def bracket_options() -> List[Dict[str, Any]]:
return [{"level": b.level, "name": b.name, "desc": b.short_desc} for b in BRACKET_DEFINITIONS] return [{"level": b.level, "name": b.name, "desc": b.short_desc} for b in BRACKET_DEFINITIONS]
@ -379,7 +631,7 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
_write_status({"running": False, "phase": "error", "message": "Setup check failed"}) _write_status({"running": False, "phase": "error", "message": "Setup check failed"})
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int]) -> Dict[str, Any]: def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None) -> Dict[str, Any]:
"""Run the deck build end-to-end with provided selections and capture logs. """Run the deck build end-to-end with provided selections and capture logs.
Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] } Returns: { ok: bool, log: str, csv_path: Optional[str], txt_path: Optional[str], error: Optional[str] }
@ -426,6 +678,14 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i
# Ideal counts # Ideal counts
b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()} b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()}
# Apply tag combine mode
try:
b.tag_mode = (str(tag_mode).upper() if tag_mode else b.tag_mode)
if b.tag_mode not in ('AND','OR'):
b.tag_mode = 'AND'
except Exception:
pass
# Load data and run phases # Load data and run phases
try: try:
b.determine_color_identity() b.determine_color_identity()
@ -562,7 +822,7 @@ def _make_stages(b: DeckBuilder) -> List[Dict[str, Any]]:
return stages return stages
def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int]) -> Dict[str, Any]: def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None) -> Dict[str, Any]:
logs: List[str] = [] logs: List[str] = []
def out(msg: str) -> None: def out(msg: str) -> None:
@ -597,6 +857,14 @@ def start_build_ctx(commander: str, tags: List[str], bracket: int, ideals: Dict[
b.bracket_limits = dict(getattr(bd, 'limits', {})) b.bracket_limits = dict(getattr(bd, 'limits', {}))
# Ideals # Ideals
b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()} b.ideal_counts = {k: int(v) for k, v in (ideals or {}).items()}
# Apply tag combine mode
try:
b.tag_mode = (str(tag_mode).upper() if tag_mode else b.tag_mode)
if b.tag_mode not in ('AND','OR'):
b.tag_mode = 'AND'
except Exception:
pass
# Data load # Data load
b.determine_color_identity() b.determine_color_identity()
b.setup_dataframes() b.setup_dataframes()

View file

@ -21,6 +21,8 @@
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); } body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); }
/* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; }
/* Top banner */ /* Top banner */
.top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); } .top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; } .top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MTG Deckbuilder</title> <title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script> <script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css?v=20250826-1" />
</head> </head>
<body> <body>
<header class="top-banner"> <header class="top-banner">
@ -38,6 +38,11 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
<footer class="site-footer" role="contentinfo">
Card images and data provided by
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
</footer>
<style> <style>
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; } .card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; } .card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
@ -45,6 +50,8 @@
.card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 280px; font-size: 12px; line-height: 1.35; box-shadow: 0 6px 18px rgba(0,0,0,.35); } .card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 280px; font-size: 12px; line-height: 1.35; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; } .card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
.card-meta .line + .line { margin-top:.35rem; } .card-meta .line + .line { margin-top:.35rem; }
.site-footer { margin: 12px 16px 0; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
.site-footer a { color: #cbd5e1; text-decoration: underline; }
</style> </style>
<script> <script>
(function(){ (function(){

View file

@ -1,28 +1,44 @@
<section> <section>
<h3>Step 1: Choose a Commander</h3> <h3>Step 1: Choose a Commander</h3>
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML"> <form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML" aria-label="Commander search form" role="search">
<label>Search by name</label> <label for="cmdr-search">Search by name</label>
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" /> <span class="input-wrap">
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" aria-describedby="cmdr-help" aria-controls="candidate-grid" placeholder="Type a commander name…" />
<button id="cmdr-clear" type="button" class="clear-btn" title="Clear search" aria-label="Clear search" hidden>×</button>
</span>
<input id="active-name" type="hidden" name="active" value="{{ active or '' }}" />
<button type="submit">Search</button> <button type="submit">Search</button>
<label style="margin-left:.5rem; font-weight:normal;"> <label style="margin-left:.5rem; font-weight:normal;">
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident) <input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
</label> </label>
<span id="search-spinner" class="spinner" aria-hidden="true" hidden style="display:none;"></span>
</form> </form>
<div class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;"> <div id="cmdr-help" class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
Tip: Press Enter to select the highlighted result, or use Up/Down to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized. Tip: Press Enter to select the highlighted result, or use arrow keys to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
</div>
<div id="selection-live" class="sr-only" aria-live="polite" role="status"></div>
<div id="results-live" class="sr-only" aria-live="polite" role="status"></div>
<div id="kbd-hint" class="hint" hidden>
<span class="hint-text">Use
<span class="keys"><kbd></kbd><kbd></kbd></span> to navigate, <kbd>Enter</kbd> to select
</span>
<button type="button" class="hint-close" title="Dismiss keyboard hint" aria-label="Dismiss">×</button>
</div> </div>
{% if candidates %} {% if candidates %}
<h4>Top matches</h4> <h4 style="display:flex; align-items:center; gap:.5rem;">
<div class="candidate-grid" id="candidate-grid"> Top matches
<small class="muted" aria-live="polite">{% if count is defined %}{{ count }} result{% if count != 1 %}s{% endif %}{% else %}{{ (candidates|length) if candidates else 0 }} results{% endif %}</small>
</h4>
<div class="candidate-grid" id="candidate-grid" role="list">
{% for name, score, colors in candidates %} {% for name, score, colors in candidates %}
<div class="candidate-tile" data-card-name="{{ name }}"> <div class="candidate-tile{% if active and active == name %} active{% endif %}" data-card-name="{{ name }}" role="listitem" aria-selected="{% if active and active == name %}true{% else %}false{% endif %}">
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML"> <form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ name }}" /> <input type="hidden" name="name" value="{{ name }}" />
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})"> <button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" <img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal"
alt="{{ name }}" /> alt="{{ name }}" loading="lazy" decoding="async" />
</button> </button>
</form> </form>
<div class="meta"> <div class="meta">
@ -47,6 +63,12 @@
</div> </div>
{% endif %} {% endif %}
{% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %}
<div id="candidate-grid" class="muted" style="margin-top:.5rem;" aria-live="polite">
No results for “{{ query }}”. Try a shorter name or a different spelling.
</div>
{% endif %}
{% if inspect and inspect.ok %} {% if inspect and inspect.ok %}
<div class="two-col two-col-left-rail"> <div class="two-col two-col-left-rail">
<aside class="card-preview card-sm" data-card-name="{{ selected }}"> <aside class="card-preview card-sm" data-card-name="{{ selected }}">
@ -94,7 +116,58 @@
var input = document.getElementById('cmdr-search'); var input = document.getElementById('cmdr-search');
var form = document.getElementById('cmdr-search-form'); var form = document.getElementById('cmdr-search-form');
var grid = document.getElementById('candidate-grid'); var grid = document.getElementById('candidate-grid');
var spinner = document.getElementById('search-spinner');
var activeField = document.getElementById('active-name');
var selLive = document.getElementById('selection-live');
var resultsLive = document.getElementById('results-live');
var hint = document.getElementById('kbd-hint');
var defaultPlaceholder = (input && input.placeholder) ? input.placeholder : 'Type a commander name…';
var clearBtn = document.getElementById('cmdr-clear');
var initialDescribedBy = (input && input.getAttribute('aria-describedby')) || '';
// Persist auto-select preference
try {
var autoCb = document.querySelector('input[name="auto"][type="checkbox"]');
if (autoCb) {
var saved = localStorage.getItem('step1-auto');
if (saved === '1' || saved === '0') autoCb.checked = (saved === '1');
autoCb.addEventListener('change', function(){ localStorage.setItem('step1-auto', autoCb.checked ? '1' : '0'); });
}
} catch(_){ }
if (!input || !form) return; if (!input || !form) return;
// Show keyboard hint only when candidates exist and user hasn't dismissed it
function showHintIfNeeded() {
try {
if (!hint) return;
var dismissed = localStorage.getItem('step1-hint-dismissed') === '1';
var hasTiles = !!(document.getElementById('candidate-grid') && document.getElementById('candidate-grid').querySelector('.candidate-tile'));
var shouldShow = !(dismissed || !hasTiles);
hint.hidden = !shouldShow;
// Link hint to input a11y description only when visible
if (input) {
var base = initialDescribedBy.trim();
var parts = base ? base.split(/\s+/) : [];
var idx = parts.indexOf('kbd-hint');
if (shouldShow) {
if (idx === -1) parts.push('kbd-hint');
} else {
if (idx !== -1) parts.splice(idx, 1);
}
if (parts.length) input.setAttribute('aria-describedby', parts.join(' '));
else input.removeAttribute('aria-describedby');
}
} catch(_) { /* noop */ }
}
showHintIfNeeded();
// Close button for hint
try {
var closeBtn = hint ? hint.querySelector('.hint-close') : null;
if (closeBtn) {
closeBtn.addEventListener('click', function(){
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
});
}
} catch(_){ }
// Debounce live search // Debounce live search
var t = null; var t = null;
function submit(){ function submit(){
@ -106,15 +179,48 @@
input.addEventListener('input', function(){ input.addEventListener('input', function(){
if (t) clearTimeout(t); if (t) clearTimeout(t);
t = setTimeout(submit, 250); t = setTimeout(submit, 250);
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
});
// Initialize clear visibility
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
if (clearBtn) clearBtn.addEventListener('click', function(){
if (!input) return;
input.value = '';
try { clearBtn.hidden = true; } catch(_){ }
if (t) clearTimeout(t);
t = setTimeout(submit, 0);
try { input.focus(); } catch(_){}
});
// Focus the search box on load if nothing else is focused
try {
var ae = document.activeElement;
if (input && (!ae || ae === document.body)) { input.focus(); input.select && input.select(); }
} catch(_){}
// Quick focus: press "/" to focus the search input (unless already typing)
document.addEventListener('keydown', function(e){
if (e.key !== '/') return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (input) { input.focus(); try { input.select(); } catch(_){} }
}); });
// Keyboard navigation: up/down to move selection, Enter to choose/inspect // Keyboard navigation: up/down to move selection, Enter to choose/inspect
document.addEventListener('keydown', function(e){ document.addEventListener('keydown', function(e){
// Dismiss hint on first keyboard navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') {
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
}
if (!grid || !grid.children || grid.children.length === 0) return; if (!grid || !grid.children || grid.children.length === 0) return;
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile')); var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
// Ensure something is selected by default // Ensure something is selected by default
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); }); var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
if (idx < 0 && tiles.length > 0) { if (idx < 0 && tiles.length > 0) {
tiles[0].classList.add('active'); tiles[0].classList.add('active');
try { if (activeField) activeField.value = tiles[0].dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[0].dataset.cardName || ''); } catch(_){}
idx = 0; idx = 0;
} }
@ -129,8 +235,11 @@
function setActive(newIdx) { function setActive(newIdx) {
// Clamp to bounds; wrapping handled by callers // Clamp to bounds; wrapping handled by callers
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx)); newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
tiles.forEach(function(el){ el.classList.remove('active'); }); tiles.forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
tiles[newIdx].classList.add('active'); tiles[newIdx].classList.add('active');
tiles[newIdx].setAttribute('aria-selected', 'true');
try { if (activeField) activeField.value = tiles[newIdx].dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[newIdx].dataset.cardName || ''); } catch(_){}
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' }); tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
return newIdx; return newIdx;
} }
@ -178,8 +287,30 @@
if (btn) btn.click(); if (btn) btn.click();
} }
} }
} else if (e.key === 'Escape') {
// ESC clears the search field and triggers a refresh
if (input && input.value) {
input.value = '';
if (t) clearTimeout(t);
t = setTimeout(submit, 0);
}
} }
}); });
// Persist current active on click selection movement too
if (grid) {
grid.addEventListener('click', function(e){
// Dismiss hint on interaction
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
if (hint) hint.hidden = true;
var tile = e.target.closest('.candidate-tile');
if (!tile) return;
grid.querySelectorAll('.candidate-tile').forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
tile.classList.add('active');
tile.setAttribute('aria-selected', 'true');
try { if (activeField) activeField.value = tile.dataset.cardName || ''; } catch(_){}
try { if (selLive) selLive.textContent = 'Selected ' + (tile.dataset.cardName || ''); } catch(_){}
});
}
// Highlight matched text // Highlight matched text
try { try {
var q = (input.value || '').trim().toLowerCase(); var q = (input.value || '').trim().toLowerCase();
@ -194,6 +325,44 @@
}); });
} }
} catch(_){} } catch(_){}
// HTMX spinner binding for this form — only show if no results are currently displayed
if (window.htmx && form) {
form.addEventListener('htmx:beforeRequest', function(){
var hasTiles = false;
try { hasTiles = !!(grid && grid.querySelector('.candidate-tile')); } catch(_){}
if (spinner) spinner.hidden = hasTiles ? true : false;
if (!hasTiles && input) input.placeholder = 'Searching…';
try { form.setAttribute('aria-busy', 'true'); } catch(_){ }
if (resultsLive) resultsLive.textContent = 'Searching…';
});
form.addEventListener('htmx:afterSwap', function(){
if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder;
// After swap, if there are no candidate tiles, clear active selection and live text
try {
var grid2 = document.getElementById('candidate-grid');
var hasAny = !!(grid2 && grid2.querySelector('.candidate-tile'));
if (!hasAny) {
if (activeField) activeField.value = '';
if (selLive) selLive.textContent = '';
}
// Re-evaluate hint visibility post-swap
showHintIfNeeded();
// Announce results count
try {
var qNow = (input && input.value) ? input.value.trim() : '';
var cnt = 0;
if (grid2) cnt = grid2.querySelectorAll('.candidate-tile').length;
if (resultsLive) {
if (cnt > 0) resultsLive.textContent = cnt + (cnt === 1 ? ' result' : ' results');
else if (qNow) resultsLive.textContent = 'No results for "' + qNow + '"';
else resultsLive.textContent = '';
}
} catch(_){ }
try { form.removeAttribute('aria-busy'); } catch(_){ }
} catch(_){ }
});
form.addEventListener('htmx:responseError', function(){ if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder; });
}
})(); })();
</script> </script>
<style> <style>
@ -211,4 +380,15 @@
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; } .chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; } mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
.candidate-tile { cursor: pointer; } .candidate-tile { cursor: pointer; }
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.spinner { display:inline-block; width:16px; height:16px; border:2px solid #93c5fd; border-top-color: transparent; border-radius:50%; animation: spin 0.8s linear infinite; vertical-align:middle; margin-left:.4rem; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Ensure hidden attribute always hides spinner within this fragment */
.spinner[hidden] { display: none !important; }
.hint { display:flex; align-items:center; gap:.5rem; background:#0b1220; border:1px solid var(--border); color:#cbd5e1; padding:.4rem .6rem; border-radius:8px; margin:.4rem 0 .6rem; }
.hint .hint-close { background:transparent; border:0; color:#9aa4b2; font-size:1rem; line-height:1; cursor:pointer; }
.hint .keys kbd { background:#1f2937; color:#e5e7eb; padding:.1rem .3rem; border-radius:4px; margin:0 .1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size:.85em; }
.input-wrap { position: relative; display:inline-flex; align-items:center; }
.clear-btn { position:absolute; right:.35rem; background:transparent; color:#9aa4b2; border:0; cursor:pointer; font-size:1.1rem; line-height:1; padding:.1rem .2rem; }
.clear-btn:hover { color:#cbd5e1; }
</style> </style>

View file

@ -18,30 +18,55 @@
<fieldset> <fieldset>
<legend>Theme Tags</legend> <legend>Theme Tags</legend>
{% if tags %} {% if tags %}
<label>Primary <input type="hidden" name="primary_tag" id="primary_tag" value="{{ primary_tag or '' }}" />
<select name="primary_tag"> <input type="hidden" name="secondary_tag" id="secondary_tag" value="{{ secondary_tag or '' }}" />
<option value="">-- none --</option> <input type="hidden" name="tertiary_tag" id="tertiary_tag" value="{{ tertiary_tag or '' }}" />
{% for t in tags %} <input type="hidden" name="tag_mode" id="tag_mode" value="{{ tag_mode or 'AND' }}" />
<option value="{{ t }}" {% if t == primary_tag %}selected{% endif %}>{{ t }}</option> <div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
{% endfor %} <div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
</select> <span class="muted" style="font-size:12px;">Combine</span>
</label> <div role="group" aria-label="Combine mode" aria-describedby="combine-help-tip">
<label>Secondary <label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
<select name="secondary_tag"> <input type="radio" name="combine_mode_radio" value="AND" {% if (tag_mode or 'AND') == 'AND' %}checked{% endif %} /> AND
<option value="">-- none --</option> </label>
{% for t in tags %} <label title="OR treats your themes as a union (broader pool, fills easier).">
<option value="{{ t }}" {% if t == secondary_tag %}selected{% endif %}>{{ t }}</option> <input type="radio" name="combine_mode_radio" value="OR" {% if tag_mode == 'OR' %}checked{% endif %} /> OR
{% endfor %} </label>
</select> </div>
</label> <button type="button" id="reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
<label>Tertiary <span id="tag-count" class="muted" style="font-size:12px;"></span>
<select name="tertiary_tag"> </div>
<option value="">-- none --</option> <div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
{% for t in tags %} <div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
<option value="{{ t }}" {% if t == tertiary_tag %}selected{% endif %}>{{ t }}</option> {% if recommended and recommended|length %}
{% endfor %} <div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
</select> <div class="muted" style="font-size:12px;">Recommended</div>
</label> <button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
</div>
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
<ul style="margin:.25rem 0; padding-left:1.1rem;">
{% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
{% endfor %}
</ul>
</div>
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% for r in recommended %}
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
{% endfor %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
</div>
{% endif %}
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button>
{% endfor %}
</div>
{% else %} {% else %}
<p>No theme tags available for this commander.</p> <p>No theme tags available for this commander.</p>
{% endif %} {% endif %}
@ -75,3 +100,213 @@
</div> </div>
</div> </div>
</section> </section>
<script>
(function(){
var chipHost = document.getElementById('tag-chip-list');
var recoHost = document.getElementById('tag-reco-list');
var selAll = document.getElementById('reco-select-all');
var resetBtn = document.getElementById('reset-tags');
var primary = document.getElementById('primary_tag');
var secondary = document.getElementById('secondary_tag');
var tertiary = document.getElementById('tertiary_tag');
var tagMode = document.getElementById('tag_mode');
var countEl = document.getElementById('tag-count');
var orderEl = document.getElementById('tag-order');
var commander = '{{ commander.name|e }}';
if (!chipHost) return;
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
function getSelected(){
var arr = [];
if (primary && primary.value) arr.push(primary.value);
if (secondary && secondary.value) arr.push(secondary.value);
if (tertiary && tertiary.value) arr.push(tertiary.value);
return arr;
}
function setSelected(arr){
arr = Array.from(new Set(arr || [])).filter(Boolean).slice(0,3);
if (primary) primary.value = arr[0] || '';
if (secondary) secondary.value = arr[1] || '';
if (tertiary) tertiary.value = arr[2] || '';
updateCount();
persist();
updateOrderUI();
}
function toggleTag(t){
var cur = getSelected();
var idx = cur.indexOf(t);
if (idx >= 0) { cur.splice(idx, 1); }
else {
if (cur.length >= 3) { cur = cur.slice(1); }
cur.push(t);
}
setSelected(cur);
updateChipsState();
}
function updateCount(){
try { if (countEl) countEl.textContent = getSelected().length + ' / 3 selected'; } catch(_){}
}
function persist(){
try {
localStorage.setItem(storageKey('tags'), JSON.stringify(getSelected()));
if (tagMode) localStorage.setItem(storageKey('mode'), tagMode.value || 'AND');
} catch(_){}
}
function loadPersisted(){
try {
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }
if (tagMode) { tagMode.value = (savedMode === 'OR' ? 'OR' : 'AND'); }
// sync radios
syncModeRadios();
} catch(_){}
}
function syncModeRadios(){
try {
var radios = document.querySelectorAll('input[name="combine_mode_radio"]');
Array.prototype.forEach.call(radios, function(r){ r.checked = (r.value === (tagMode && tagMode.value || 'AND')); });
} catch(_){}
}
function updateChipsState(){
var sel = getSelected();
function applyToContainer(container){
if (!container) return;
var chips = Array.prototype.slice.call(container.querySelectorAll('button.chip'));
chips.forEach(function(btn){
var t = btn.dataset.tag || '';
var active = sel.indexOf(t) >= 0;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
// update numeric badge for order
var old = btn.querySelector('sup.tag-order');
if (old) { try { old.remove(); } catch(_){} }
if (active){
var idx = sel.indexOf(t);
if (idx >= 0){
var sup = document.createElement('sup');
sup.className = 'tag-order';
sup.style.marginLeft = '.25rem';
sup.style.opacity = '.75';
sup.textContent = String(idx + 1);
btn.appendChild(sup);
}
}
});
}
applyToContainer(chipHost);
applyToContainer(recoHost);
updateCount();
updateOrderUI();
updateSelectAllState();
}
function updateOrderUI(){
if (!orderEl) return;
var sel = getSelected();
if (!sel.length){ orderEl.textContent = ''; return; }
try {
var parts = sel.map(function(t, i){ return (i+1) + '. ' + t; });
orderEl.textContent = 'Selected order: ' + parts.join(' • ');
} catch(_){ orderEl.textContent = ''; }
}
// bind mode radios
Array.prototype.forEach.call(document.querySelectorAll('input[name="combine_mode_radio"]'), function(r){
r.addEventListener('change', function(){ if (tagMode) { tagMode.value = r.value; persist(); } });
});
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
// attach handlers to existing chips
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
btn.addEventListener('keydown', function(e){
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
var ix = chips.indexOf(e.currentTarget);
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
if (next) { try { next.focus(); } catch(_){ } }
}
});
});
// attach handlers to recommended chips and select-all
if (recoHost){
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
var t = btn.dataset.tag || '';
btn.addEventListener('click', function(){ toggleTag(t); });
});
if (selAll){
selAll.addEventListener('click', function(){
try {
var sel = getSelected();
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
var combined = sel.slice();
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
combined = combined.slice(-3); // keep last 3
setSelected(combined);
updateChipsState();
updateSelectAllState();
} catch(_){ }
});
}
// Why recommended panel toggle
var whyBtn = document.getElementById('reco-why');
var whyPanel = document.getElementById('reco-why-panel');
function setWhy(open){
if (!whyBtn || !whyPanel) return;
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
whyPanel.style.display = open ? 'block' : 'none';
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
}
if (whyBtn && whyPanel){
whyBtn.addEventListener('click', function(e){
e.stopPropagation();
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
setWhy(!isOpen);
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
});
document.addEventListener('click', function(e){
try {
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
if (!isOpen) return;
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
setWhy(false);
} catch(_){}
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape'){ setWhy(false); }
});
}
}
function updateSelectAllState(){
try {
if (!selAll) return;
var sel = getSelected();
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
var atCap = sel.length >= 3;
var noNew = unselected.length === 0;
var disable = atCap || noNew;
selAll.disabled = disable;
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
if (disable){
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
} else {
selAll.title = 'Add recommended up to 3';
}
} catch(_){ }
}
// initial: set from template-selected values, then maybe load persisted if none
updateChipsState();
loadPersisted();
updateChipsState();
})();
</script>

View file

@ -3,7 +3,7 @@
<h2>Build from JSON: {{ cfg_name }}</h2> <h2>Build from JSON: {{ cfg_name }}</h2>
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p> <p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
{% if commander %} {% if commander %}
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong></div> <div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}</div>
{% endif %} {% endif %}
<div class="two-col two-col-left-rail"> <div class="two-col two-col-left-rail">

View file

@ -7,6 +7,7 @@
<div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;"> <div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;">
<div>Commander</div><div>{{ data.commander }}</div> <div>Commander</div><div>{{ data.commander }}</div>
<div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div> <div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div>
<div>Combine Mode</div><div>{{ (data.tag_mode or data.combine_mode or 'AND') | upper }}</div>
<div>Bracket</div><div>{{ data.bracket_level }}</div> <div>Bracket</div><div>{{ data.bracket_level }}</div>
</div> </div>
</details> </details>

View file

@ -1,21 +1,38 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block banner_subtitle %}Finished Decks{% endblock %} {% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %} {% block content %}
<h2>Finished Decks</h2> <h2 id="decks-heading">Finished Decks</h2>
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p> <p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
{% if error %} {% if error %}
<div class="error">{{ error }}</div> <div class="error">{{ error }}</div>
{% endif %} {% endif %}
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center;"> <div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" /> <input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" aria-controls="deck-list" />
<select id="deck-sort" aria-label="Sort decks">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name-asc">Commander AZ</option>
<option value="name-desc">Commander ZA</option>
</select>
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
<input type="checkbox" id="deck-txt-only" /> TXT only
</label>
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
<button id="deck-reset-all" type="button" title="Reset filter, sort, and tags">Reset all</button>
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
<span id="deck-count" class="muted" aria-live="polite"></span>
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
</div> </div>
<div id="tag-label" class="muted" style="font-size:12px; margin:.15rem 0 .25rem 0;">Theme filters</div>
<div id="tag-chips" aria-labelledby="tag-label" style="display:flex; gap:.25rem; flex-wrap:wrap; margin:.25rem 0 .75rem 0;"></div>
{% if items %} {% if items %}
<div id="deck-list" style="list-style:none; padding:0; margin:0; display:block;"> <div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
{% for it in items %} {% for it in items %}
<div class="panel" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" style="margin:0 0 .5rem 0;"> <div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;"> <div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
<div> <div>
<div> <div>
@ -24,17 +41,54 @@
{% if it.tags and it.tags|length %} {% if it.tags and it.tags|length %}
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div> <div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
{% endif %} {% endif %}
<div class="muted" style="font-size:12px;">
{% if it.mtime is defined %}
<span title="Modified">{{ it.mtime | int }}</span>
{% endif %}
</div>
</div> </div>
<div style="display:flex; gap:.35rem;"> <div style="display:flex; gap:.35rem; align-items:center;">
<form action="/files" method="get" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ it.path }}" />
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
</form>
{% if it.txt_path %}
<form action="/files" method="get" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ it.txt_path }}" />
<button type="submit" title="Download TXT" aria-label="Download TXT for {{ it.commander }}">TXT</button>
</form>
{% endif %}
<form action="/decks/view" method="get" style="display:inline; margin:0;"> <form action="/decks/view" method="get" style="display:inline; margin:0;">
<input type="hidden" name="name" value="{{ it.name }}" /> <input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit">Open</button> <button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div id="deck-empty" class="muted" style="display:none; margin-top:.5rem;">No decks match your filters.</div>
<!-- Help modal -->
<div id="deck-help-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="deck-help-title" hidden>
<div class="modal-backdrop" id="deck-help-backdrop"></div>
<div class="modal-content" role="document">
<div class="modal-header">
<h3 id="deck-help-title" style="margin:0;">Keyboard and tips</h3>
<button type="button" id="deck-help-close" aria-label="Close help">×</button>
</div>
<div class="modal-body">
<ul style="margin:.25rem 0 0 1rem;">
<li><kbd>/</kbd> focuses the filter</li>
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
<li><kbd>R</kbd> resets all filters, sort, and tags</li>
<li>Use “TXT only” to show only decks that have a TXT export</li>
<li>Share copies a link with your current filters</li>
</ul>
</div>
</div>
</div>
{% else %} {% else %}
<div class="muted">No exports yet. Run a build to create one.</div> <div class="muted">No exports yet. Run a build to create one.</div>
{% endif %} {% endif %}
@ -42,14 +96,459 @@
<script> <script>
(function(){ (function(){
var input = document.getElementById('deck-filter'); var input = document.getElementById('deck-filter');
if (!input) return; var sortSel = document.getElementById('deck-sort');
input.addEventListener('input', function(){ var clearBtn = document.getElementById('deck-clear');
var q = (input.value || '').toLowerCase(); var list = document.getElementById('deck-list');
document.querySelectorAll('#deck-list .panel').forEach(function(row){ var chips = document.getElementById('tag-chips');
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase(); var countEl = document.getElementById('deck-count');
row.style.display = hay.indexOf(q) >= 0 ? '' : 'none'; var shareBtn = document.getElementById('deck-share');
var resetAllBtn = document.getElementById('deck-reset-all');
var liveEl = document.getElementById('deck-live');
var emptyEl = document.getElementById('deck-empty');
var helpBtn = document.getElementById('deck-help');
var helpModal = document.getElementById('deck-help-modal');
var helpClose = document.getElementById('deck-help-close');
var helpBackdrop = document.getElementById('deck-help-backdrop');
var txtOnlyCb = document.getElementById('deck-txt-only');
if (!list) return;
// Build tag chips from data-tags-pipe
var tagSet = new Set();
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
panels.forEach(function(p){
var raw = p.dataset.tagsPipe || '';
raw.split('|').forEach(function(t){ if (t && t.trim()) tagSet.add(t.trim()); });
});
var activeTags = new Set();
// URL hash <-> state sync helpers
function parseHash(){
try {
var h = (location.hash || '').replace(/^#/, '');
if (!h) return null;
var qp = new URLSearchParams(h);
var q = qp.get('q') || '';
var sort = qp.get('sort') || '';
var tagsStr = qp.get('tags') || '';
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
var txt = qp.get('txt');
var txtOnly = (txt === '1' || txt === 'true');
return { q: q, sort: sort, tags: tags, txt: txtOnly };
} catch(_) { return null; }
}
function updateHashFromState(){
try {
var q = (input && input.value) ? input.value.trim() : '';
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
var tags = Array.from(activeTags);
var qp = new URLSearchParams();
if (q) qp.set('q', q);
if (sort && sort !== 'newest') qp.set('sort', sort);
if (tags.length) qp.set('tags', tags.map(function(s){ return encodeURIComponent(s); }).join(','));
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
var newHash = qp.toString();
var base = location.pathname + location.search;
var current = (location.hash || '').replace(/^#/, '');
if (current !== newHash) {
history.replaceState(null, '', base + (newHash ? ('#' + newHash) : ''));
}
} catch(_){ }
}
function applyStateFromHash(){
var s = parseHash();
if (!s) return false;
var changed = false;
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
if (Array.isArray(s.tags)) { activeTags = new Set(s.tags); changed = true; }
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
renderChips();
applyAll();
return changed;
}
function renderChips(){
if (!chips) return;
chips.innerHTML = '';
Array.from(tagSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip chip-filter' + (activeTags.has(t) ? ' active' : '');
btn.textContent = t;
btn.setAttribute('aria-pressed', activeTags.has(t) ? 'true' : 'false');
btn.addEventListener('click', function(){
if (activeTags.has(t)) activeTags.delete(t); else activeTags.add(t);
renderChips();
applyAll();
});
chips.appendChild(btn);
}); });
// Reset tags control appears only when any tags are active
if (activeTags.size > 0) {
var reset = document.createElement('button');
reset.type = 'button';
reset.id = 'reset-tags';
reset.className = 'chip';
reset.textContent = 'Reset tags';
reset.title = 'Clear selected theme tags';
reset.addEventListener('click', function(){
activeTags.clear();
renderChips();
applyAll();
if (liveEl) liveEl.textContent = 'Theme tags cleared';
});
chips.appendChild(reset);
}
}
function updateCount(){
if (!countEl) return;
var total = panels.length;
var visible = panels.filter(function(p){ return p.style.display !== 'none'; }).length;
countEl.textContent = visible + ' of ' + total + ' decks';
if (emptyEl) emptyEl.style.display = (visible === 0 ? '' : 'none');
try {
if (liveEl) {
if (visible === 0) liveEl.textContent = 'No decks match your filters';
else liveEl.textContent = 'Showing ' + visible + ' of ' + total + ' decks';
}
} catch(_){ }
return { total: total, visible: visible };
}
function applyFilter(){
var q = (input && input.value || '').toLowerCase();
panels.forEach(function(row){
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
var textMatch = hay.indexOf(q) >= 0;
var tagsPipe = row.dataset.tagsPipe || '';
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
var tagMatch = true;
activeTags.forEach(function(t){ if (tags.indexOf(t) === -1) tagMatch = false; });
var txtOk = true;
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
});
}
function highlightMatches(){
var q = (input && input.value || '').trim();
var ql = q.toLowerCase();
panels.forEach(function(row){
var strong = row.querySelector('strong[data-card-name]');
if (!strong) return;
var raw = strong.getAttribute('data-card-name') || strong.textContent || '';
if (!q) { strong.textContent = raw; return; }
var low = raw.toLowerCase();
var i = low.indexOf(ql);
if (i >= 0) {
strong.innerHTML = raw.substring(0, i) + '<mark>' + raw.substring(i, i+q.length) + '</mark>' + raw.substring(i+q.length);
} else {
strong.textContent = raw;
}
// Also highlight in Themes: ... line if present
try {
var themeEl = Array.prototype.slice.call(row.querySelectorAll('.muted')).find(function(el){
var t = (el.textContent || '').trim().toLowerCase();
return t.startsWith('themes:');
});
if (themeEl) {
if (!themeEl.dataset.raw) { themeEl.dataset.raw = themeEl.textContent || ''; }
var base = themeEl.dataset.raw;
if (!q) { themeEl.textContent = base; }
else {
var prefix = 'Themes: ';
var rest = base.startsWith(prefix) ? base.substring(prefix.length) : base;
var li = rest.toLowerCase().indexOf(ql);
if (li >= 0) {
themeEl.innerHTML = prefix + rest.substring(0, li) + '<mark>' + rest.substring(li, li+q.length) + '</mark>' + rest.substring(li+q.length);
} else {
themeEl.textContent = base;
}
}
}
} catch(_){ }
});
}
function applySort(){
var mode = (sortSel && sortSel.value) || 'newest';
var rows = panels.slice();
rows.sort(function(a,b){
if (mode === 'newest' || mode === 'oldest'){
var am = parseFloat(a.dataset.mtime || '0');
var bm = parseFloat(b.dataset.mtime || '0');
return (mode === 'newest') ? (bm - am) : (am - bm);
} else if (mode === 'name-asc' || mode === 'name-desc'){
var ac = (a.dataset.commander || '').toLowerCase();
var bc = (b.dataset.commander || '').toLowerCase();
var cmp = ac.localeCompare(bc);
return (mode === 'name-asc') ? cmp : -cmp;
}
return 0;
});
// Re-append in new order
rows.forEach(function(r){ list.appendChild(r); });
refreshPanels();
}
function applyAll(){
applyFilter();
applySort();
highlightMatches();
var counts = updateCount();
// If focus is on a hidden panel, move to first visible
try {
var active = document.activeElement;
if (active && list.contains(active)) {
var p = active.closest('.panel');
if (p && p.style.display === 'none') {
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
if (firstVis) firstVis.focus();
}
}
} catch(_){ }
// Persist state
try {
if (input) localStorage.setItem('decks-filter', input.value || '');
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
localStorage.setItem('decks-tags', JSON.stringify(Array.from(activeTags)));
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
} catch(_){ }
// Update URL hash for shareable state
updateHashFromState();
}
// Debounce helper
function debounce(fn, delay){
var timer = null;
return function(){
var ctx = this, args = arguments;
if (timer) clearTimeout(timer);
timer = setTimeout(function(){ fn.apply(ctx, args); }, delay);
};
}
var debouncedApply = debounce(applyAll, 150);
if (input) input.addEventListener('input', debouncedApply);
if (sortSel) sortSel.addEventListener('change', applyAll);
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
if (clearBtn) clearBtn.addEventListener('click', function(){
if (input) input.value = '';
activeTags.clear();
if (sortSel) sortSel.value = 'newest';
if (txtOnlyCb) txtOnlyCb.checked = false;
renderChips();
applyAll();
}); });
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
// Clear UI state
try {
if (input) input.value = '';
if (sortSel) sortSel.value = 'newest';
if (txtOnlyCb) txtOnlyCb.checked = false;
activeTags.clear();
renderChips();
// Clear persistence
localStorage.removeItem('decks-filter');
localStorage.removeItem('decks-sort');
localStorage.removeItem('decks-tags');
localStorage.removeItem('decks-txt');
// Clear URL hash
var base = location.pathname + location.search;
history.replaceState(null, '', base);
} catch(_){ }
applyAll();
if (liveEl) liveEl.textContent = 'Filters, sort, and tags reset';
});
if (shareBtn) shareBtn.addEventListener('click', function(){
try {
// Ensure hash reflects current UI state
updateHashFromState();
var url = window.location.href;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url);
} else {
var t = document.createElement('input');
t.value = url; document.body.appendChild(t); t.select(); try { document.execCommand('copy'); } catch(_){} document.body.removeChild(t);
}
var prev = shareBtn.textContent;
shareBtn.textContent = 'Copied';
setTimeout(function(){ shareBtn.textContent = prev; }, 1200);
if (liveEl) liveEl.textContent = 'Link copied to clipboard';
} catch(_){ }
});
// Initial state: prefer URL hash, fall back to localStorage
var hadHash = false;
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
if (hadHash) {
renderChips();
if (!applyStateFromHash()) { applyAll(); }
} else {
// Load persisted state
try {
var savedFilter = localStorage.getItem('decks-filter') || '';
if (input) input.value = savedFilter;
var savedSort = localStorage.getItem('decks-sort') || 'newest';
if (sortSel) sortSel.value = savedSort;
var savedTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
if (Array.isArray(savedTags)) savedTags.forEach(function(t){ activeTags.add(t); });
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
} catch(_){ }
renderChips();
applyAll();
}
// React to external hash changes
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
// Open deck: keyboard and mouse helpers on panels
function getPanelUrl(p){
try {
var name = p.getAttribute('data-name') || '';
if (name) return '/decks/view?name=' + encodeURIComponent(name);
var form = p.querySelector('form[action="/decks/view"]');
if (form) {
var nameInput = form.querySelector('input[name="name"]');
if (nameInput && nameInput.value) return '/decks/view?name=' + encodeURIComponent(nameInput.value);
}
} catch(_){ }
return '/decks/view';
}
function openPanel(p, newTab){
if (!p) return;
if (newTab) { window.open(getPanelUrl(p), '_blank'); return; }
var openForm = p.querySelector('form[action="/decks/view"]');
if (openForm) {
if (window.htmx) { window.htmx.trigger(openForm, 'submit'); }
else if (openForm.submit) { openForm.submit(); }
} else { window.location.href = getPanelUrl(p); }
}
list.addEventListener('dblclick', function(e){
var p = e.target.closest('.panel');
if (!p) return;
// Ignore when double-clicking interactive controls
if (e.target.closest('button, a, input, select, textarea, label, form')) return;
openPanel(p);
});
list.addEventListener('keydown', function(e){
var p = e.target.closest('.panel[tabindex]');
if (!p) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
var newTab = !!(e.ctrlKey || e.metaKey || e.shiftKey);
openPanel(p, newTab);
}
});
// Arrow key navigation between visible panels
document.addEventListener('keydown', function(e){
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;
var active = document.activeElement;
if (!active || !list.contains(active)) return;
var vis = Array.prototype.slice.call(list.querySelectorAll('.panel')).filter(function(p){ return p.style.display !== 'none'; });
if (!vis.length) return;
var idx = vis.indexOf(active.closest('.panel'));
if (idx === -1) return;
e.preventDefault();
var target = null;
if (e.key === 'ArrowDown') target = vis[Math.min(idx + 1, vis.length - 1)];
else if (e.key === 'ArrowUp') target = vis[Math.max(idx - 1, 0)];
else if (e.key === 'Home') target = vis[0];
else if (e.key === 'End') target = vis[vis.length - 1];
if (target) { try { target.focus(); } catch(_){ } }
});
// ESC clears filter when focused in the filter input
if (input) {
input.addEventListener('keydown', function(e){
if (e.key === 'Escape' && input.value) {
input.value = '';
debouncedApply();
} else if (e.key === 'Enter') {
// Open first visible deck when pressing Enter in filter
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
if (firstVis) { e.preventDefault(); openPanel(firstVis, !!(e.ctrlKey||e.metaKey||e.shiftKey)); }
}
});
}
// Quick focus: '/' focuses filter when not typing elsewhere
document.addEventListener('keydown', function(e){
if (e.key !== '/') return;
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (input) { input.focus(); try { input.select(); } catch(_){} }
});
// Global shortcut: 'R' to reset all (when not typing)
document.addEventListener('keydown', function(e){
if ((e.key === 'r' || e.key === 'R') && !(e.ctrlKey || e.altKey || e.metaKey)) {
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
if (resetAllBtn) { e.preventDefault(); resetAllBtn.click(); }
}
});
// Help modal wiring
(function(){
if (!helpBtn || !helpModal) return;
var prevFocus = null;
function openHelp(){
prevFocus = document.activeElement;
helpModal.hidden = false;
try { document.body.dataset.prevOverflow = document.body.style.overflow || ''; document.body.style.overflow = 'hidden'; } catch(_){ }
var close = helpClose || helpModal.querySelector('button');
if (close) try { close.focus(); } catch(_){ }
}
function closeHelp(){
helpModal.hidden = true;
try { document.body.style.overflow = document.body.dataset.prevOverflow || ''; } catch(_){ }
if (prevFocus) try { prevFocus.focus(); } catch(_){ }
}
helpBtn.addEventListener('click', openHelp);
if (helpClose) helpClose.addEventListener('click', closeHelp);
if (helpBackdrop) helpBackdrop.addEventListener('click', closeHelp);
document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && !helpModal.hidden) { e.preventDefault(); closeHelp(); } });
document.addEventListener('keydown', function(e){
if ((e.key === '?' || (e.shiftKey && e.key === '/')) && !(e.ctrlKey||e.metaKey||e.altKey)){
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
if (isEditable) return;
e.preventDefault();
if (helpModal.hidden) openHelp(); else closeHelp();
}
});
})();
// Enhance mtime display to human-readable date
try {
panels.forEach(function(p){
var m = parseFloat(p.dataset.mtime || '0');
if (!m) return;
var el = p.querySelector('[title="Modified"]');
if (el) {
try { el.textContent = new Date(m * 1000).toLocaleString(); } catch(_){}
}
});
} catch(_){ }
// (copy button removed)
})(); })();
</script> </script>
<style>
.chip-filter { cursor:pointer; user-select:none; }
.chip-filter.active { background:#2563eb; color:#fff; border-color:#1d4ed8; }
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
</style>
{% endblock %} {% endblock %}