feat: add pool size badges, sorting, and optional sections to theme picker (#58)

This commit is contained in:
mwisnowski 2026-03-20 12:00:39 -07:00 committed by GitHub
parent 8efdc77c08
commit 94a43e9273
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 465 additions and 86 deletions

View file

@ -51,6 +51,7 @@ SHOW_THEME_QUALITY_BADGES=1 # dockerhub: SHOW_THEME_QUALITY_BADGES="1" (
SHOW_THEME_POOL_BADGES=1 # dockerhub: SHOW_THEME_POOL_BADGES="1" (show pool size badges in theme catalog) SHOW_THEME_POOL_BADGES=1 # dockerhub: SHOW_THEME_POOL_BADGES="1" (show pool size badges in theme catalog)
SHOW_THEME_POPULARITY_BADGES=1 # dockerhub: SHOW_THEME_POPULARITY_BADGES="1" (show popularity badges in theme catalog) SHOW_THEME_POPULARITY_BADGES=1 # dockerhub: SHOW_THEME_POPULARITY_BADGES="1" (show popularity badges in theme catalog)
SHOW_THEME_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog) SHOW_THEME_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog)
# THEME_POOL_SECTIONS=1 # dockerhub: THEME_POOL_SECTIONS="0" (1=group themes by pool size sections: Vast/Large/Moderate/Small/Tiny; 0=flat sorted list)
ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0" ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"

View file

@ -9,6 +9,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Deck Builder Theme Selection**: Enhanced theme picker with pool size indicators, smart sorting, and optional grouping
- **Pool Size Badges**: Numeric card count displayed on all theme chips (recommended + general)
- **Smart Sorting**: Themes automatically sorted by pool size (descending), then alphabetically
- **Visual Separator**: Clear separation between recommended and general themes with section headers
- **Pool Size Sections**: Optional grouping of themes into Vast/Large/Moderate/Small/Tiny sections, controlled by `THEME_POOL_SECTIONS` environment variable (default: off)
- **Popup Wizard Parity**: New Deck modal has full feature parity with the legacy builder (badges, sorting, sections)
- **Partner-Aware Sections**: When a partner commander is selected, partner themes are bucketed into the correct pool size section rather than appended flat
- **Pool Size Tooltips**: Section headers and the "All Available Themes" label include tooltips explaining what the card count badge means and the tier thresholds
- **Badge Styling**: Muted, compact pool size badges integrated seamlessly into chip design
- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality` - **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality`
- **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor)
- **Catalog Statistics**: Total themes, average quality score displayed prominently - **Catalog Statistics**: Total themes, average quality score displayed prominently

View file

@ -2,6 +2,15 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Deck Builder Theme Selection**: Enhanced theme picker with pool size indicators, smart sorting, and optional grouping
- **Pool Size Badges**: Numeric card count displayed on all theme chips (recommended + general)
- **Smart Sorting**: Themes automatically sorted by pool size (descending), then alphabetically
- **Visual Separator**: Clear separation between recommended and general themes with section headers
- **Pool Size Sections**: Optional grouping of themes into Vast/Large/Moderate/Small/Tiny sections, controlled by `THEME_POOL_SECTIONS` environment variable (default: off)
- **Popup Wizard Parity**: New Deck modal has full feature parity with the legacy builder (badges, sorting, sections)
- **Partner-Aware Sections**: When a partner commander is selected, partner themes are bucketed into the correct pool size section rather than appended flat
- **Pool Size Tooltips**: Section headers and the "All Available Themes" label include tooltips explaining what the card count badge means and the tier thresholds
- **Badge Styling**: Muted, compact pool size badges integrated seamlessly into chip design
- **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality` - **Theme Quality Dashboard**: Diagnostic dashboard for monitoring catalog health at `/diagnostics/quality`
- **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor)
- **Catalog Statistics**: Total themes, average quality score displayed prominently - **Catalog Statistics**: Total themes, average quality score displayed prominently

View file

@ -130,6 +130,10 @@ def card_image_url(card_name: str, size: str = "normal") -> str:
templates.env.filters["card_image"] = card_image_url templates.env.filters["card_image"] = card_image_url
# Add slugify filter for theme slugification (R21 M2)
from .services.theme_catalog_loader import slugify as _slugify
templates.env.filters["slugify"] = _slugify
# Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...}) # Compatibility shim: accept legacy TemplateResponse(name, {"request": request, ...})
# and reorder to the new signature TemplateResponse(request, name, {...}). # and reorder to the new signature TemplateResponse(request, name, {...}).
# Prevents DeprecationWarning noise in tests without touching all call sites. # Prevents DeprecationWarning noise in tests without touching all call sites.
@ -179,6 +183,7 @@ SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_BADGES"), Tru
SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True) SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True)
SHOW_THEME_POPULARITY_BADGES = _as_bool(os.getenv("SHOW_THEME_POPULARITY_BADGES"), True) SHOW_THEME_POPULARITY_BADGES = _as_bool(os.getenv("SHOW_THEME_POPULARITY_BADGES"), True)
SHOW_THEME_FILTERS = _as_bool(os.getenv("SHOW_THEME_FILTERS"), True) SHOW_THEME_FILTERS = _as_bool(os.getenv("SHOW_THEME_FILTERS"), True)
THEME_POOL_SECTIONS = _as_bool(os.getenv("THEME_POOL_SECTIONS"), False) # R21: Group themes by pool size
WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider' WEB_IDEALS_UI = os.getenv("WEB_IDEALS_UI", "slider").strip().lower() # 'input' or 'slider'
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True) ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True)
ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True) ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True)

View file

@ -19,6 +19,7 @@ from ..app import (
WEB_IDEALS_UI, WEB_IDEALS_UI,
ENABLE_BATCH_BUILD, ENABLE_BATCH_BUILD,
DEFAULT_THEME_MATCH_MODE, DEFAULT_THEME_MATCH_MODE,
THEME_POOL_SECTIONS,
) )
from ..services.build_utils import ( from ..services.build_utils import (
step5_ctx_from_result, step5_ctx_from_result,
@ -36,6 +37,7 @@ from .build_partners import (
_partner_ui_context, _partner_ui_context,
_resolve_partner_selection, _resolve_partner_selection,
) )
from .build_wizard import _prepare_step2_theme_data, _section_themes_by_pool_size # R21: Pool size data
from ..services import custom_theme_manager as theme_mgr from ..services import custom_theme_manager as theme_mgr
router = APIRouter() router = APIRouter()
@ -173,9 +175,20 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
info = orch.commander_select(name) info = orch.commander_select(name)
if not info.get("ok"): if not info.get("ok"):
return HTMLResponse(f'<div class="muted">Commander not found: {name}</div>') return HTMLResponse(f'<div class="muted">Commander not found: {name}</div>')
tags = orch.tags_for_commander(info["name"]) or [] tags_raw = orch.tags_for_commander(info["name"]) or []
recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] recommended_raw = orch.recommended_tags_for_commander(info["name"]) if tags_raw else []
recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags_raw else {}
# R21: Load pool size data and sort themes
tags, recommended, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
# R21: Section themes by pool size if enabled
tag_sections = []
recommended_sections = []
if THEME_POOL_SECTIONS:
tag_sections = _section_themes_by_pool_size(tags, pool_size)
recommended_sections = _section_themes_by_pool_size(recommended, pool_size)
exclusion_detail = lookup_commander_detail(info["name"]) exclusion_detail = lookup_commander_detail(info["name"])
# Render tags slot content and OOB commander preview simultaneously # Render tags slot content and OOB commander preview simultaneously
# Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer) # Game Changer flag for this commander (affects bracket UI in modal via tags partial consumer)
@ -189,10 +202,15 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
"commander": {"name": info["name"], "exclusion": exclusion_detail}, "commander": {"name": info["name"], "exclusion": exclusion_detail},
"tags": tags, "tags": tags,
"recommended": recommended, "recommended": recommended,
"pool_size": pool_size, # R21: Pool size data for badges
"use_sections": THEME_POOL_SECTIONS, # R21: Flag for template
"tag_sections": tag_sections, # R21: Sectioned themes
"recommended_sections": recommended_sections, # R21: Sectioned recommendations
"recommended_reasons": recommended_reasons, "recommended_reasons": recommended_reasons,
"gc_commander": is_gc, "gc_commander": is_gc,
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
} }
ctx.update( ctx.update(
_partner_ui_context( _partner_ui_context(
info["name"], info["name"],
@ -220,6 +238,10 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes
seen.add(key) seen.add(key)
merged_tags.append(token) merged_tags.append(token)
ctx["tags"] = merged_tags ctx["tags"] = merged_tags
# R21: Re-section merged tags if sectioning enabled
if THEME_POOL_SECTIONS:
ctx["tag_sections"] = _section_themes_by_pool_size(merged_tags, pool_size)
# Deduplicate recommended: remove any that are already in partner_tags # Deduplicate recommended: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}

View file

@ -16,7 +16,7 @@ from fastapi import APIRouter, Request, Form, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from typing import Any from typing import Any
from ..app import templates, ENABLE_PARTNER_MECHANICS from ..app import templates, ENABLE_PARTNER_MECHANICS, THEME_POOL_SECTIONS
from ..services.build_utils import ( from ..services.build_utils import (
step5_base_ctx, step5_base_ctx,
step5_ctx_from_result, step5_ctx_from_result,
@ -30,6 +30,7 @@ from ..services.build_utils import (
) )
from ..services import orchestrator as orch from ..services import orchestrator as orch
from ..services.tasks import get_session, new_sid from ..services.tasks import get_session, new_sid
from ..services.theme_catalog_loader import load_index, slugify
from deck_builder import builder_constants as bc from deck_builder import builder_constants as bc
from ..services.combo_utils import detect_all as _detect_all from ..services.combo_utils import detect_all as _detect_all
from .build_partners import _partner_ui_context, _resolve_partner_selection from .build_partners import _partner_ui_context, _resolve_partner_selection
@ -79,6 +80,71 @@ def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -
) )
def _prepare_step2_theme_data(tags: list[str], recommended: list[str]) -> tuple[list[str], list[str], dict[str, int]]:
"""
Load pool size data and sort themes for Step 2 display (R21).
Returns:
Tuple of (sorted_tags, sorted_recommended, pool_size_dict)
"""
import logging
logger = logging.getLogger(__name__)
# Load theme pool size data (R21 M1)
try:
theme_index = load_index()
pool_size_by_slug = theme_index.pool_size_by_slug
except Exception as e:
logger.warning(f"Failed to load theme index for pool sizes: {e}")
pool_size_by_slug = {}
# Sort themes by pool size (descending), then alphabetically (R21 M1)
def sort_by_pool_size(theme_list: list[str]) -> list[str]:
"""Sort themes by pool size (desc), then alphabetically."""
return sorted(
theme_list,
key=lambda t: (-pool_size_by_slug.get(slugify(t), 0), t.lower())
)
tags_sorted = sort_by_pool_size(tags)
recommended_sorted = sort_by_pool_size(recommended)
return tags_sorted, recommended_sorted, pool_size_by_slug
def _section_themes_by_pool_size(themes: list[str], pool_size: dict[str, int]) -> list[dict[str, Any]]:
"""
Group themes into sections by pool size (R21 enhancement).
Thresholds:
- Vast: 1000+
- Large: 500-999
- Moderate: 200-499
- Small: 50-199
- Tiny: <50
Returns:
List of section dicts with 'label' and 'themes' keys
"""
sections = [
{"label": "Vast", "min": 1000, "max": 9999999, "themes": []},
{"label": "Large", "min": 500, "max": 999, "themes": []},
{"label": "Moderate", "min": 200, "max": 499, "themes": []},
{"label": "Small", "min": 50, "max": 199, "themes": []},
{"label": "Tiny", "min": 0, "max": 49, "themes": []},
]
for theme in themes:
theme_pool = pool_size.get(slugify(theme), 0)
for section in sections:
if section["min"] <= theme_pool <= section["max"]:
section["themes"].append(theme)
break
# Remove empty sections
return [s for s in sections if s["themes"]]
def _current_builder_summary(sess: dict) -> Any | None: def _current_builder_summary(sess: dict) -> Any | None:
"""Get current builder's deck summary.""" """Get current builder's deck summary."""
try: try:
@ -148,16 +214,32 @@ async def build_step1_search(
sess["last_step"] = 2 sess["last_step"] = 2
commander_name = res.get("name") commander_name = res.get("name")
gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', []) gc_flag = commander_name in getattr(bc, 'GAME_CHANGERS', [])
tags_raw = orch.tags_for_commander(commander_name)
recommended_raw = orch.recommended_tags_for_commander(commander_name)
tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
# R21: Section themes by pool size if enabled
tag_sections = []
recommended_sections = []
if THEME_POOL_SECTIONS:
tag_sections = _section_themes_by_pool_size(tags_sorted, pool_size)
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
context = { context = {
"request": request, "request": request,
"commander": res, "commander": res,
"tags": orch.tags_for_commander(commander_name), "tags": tags_sorted,
"recommended": orch.recommended_tags_for_commander(commander_name), "recommended": recommended_sorted,
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name), "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
"gc_commander": gc_flag, "gc_commander": gc_flag,
"selected_bracket": (3 if gc_flag else None), "selected_bracket": (3 if gc_flag else None),
"clear_persisted": True, "clear_persisted": True,
"pool_size": pool_size,
"use_sections": THEME_POOL_SECTIONS,
"tag_sections": tag_sections,
"recommended_sections": recommended_sections,
} }
context.update( context.update(
_partner_ui_context( _partner_ui_context(
@ -251,11 +333,23 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', [])) is_gc = bool(res.get("name") in getattr(bc, 'GAME_CHANGERS', []))
except Exception: except Exception:
is_gc = False is_gc = False
tags_raw = orch.tags_for_commander(res["name"])
recommended_raw = orch.recommended_tags_for_commander(res["name"])
tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
# R21: Section themes by pool size if enabled
tag_sections = []
recommended_sections = []
if THEME_POOL_SECTIONS:
tag_sections = _section_themes_by_pool_size(tags_sorted, pool_size)
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
context = { context = {
"request": request, "request": request,
"commander": res, "commander": res,
"tags": orch.tags_for_commander(res["name"]), "tags": tags_sorted,
"recommended": orch.recommended_tags_for_commander(res["name"]), "recommended": recommended_sorted,
"recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
"gc_commander": is_gc, "gc_commander": is_gc,
@ -263,6 +357,10 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
# Signal that this navigation came from a fresh commander confirmation, # Signal that this navigation came from a fresh commander confirmation,
# so the Step 2 UI should clear any localStorage theme persistence. # so the Step 2 UI should clear any localStorage theme persistence.
"clear_persisted": True, "clear_persisted": True,
"pool_size": pool_size,
"use_sections": THEME_POOL_SECTIONS,
"tag_sections": tag_sections,
"recommended_sections": recommended_sections,
} }
context.update( context.update(
_partner_ui_context( _partner_ui_context(
@ -340,11 +438,23 @@ async def build_step2_get(request: Request) -> HTMLResponse:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}") logger.info(f"Step2 GET: commander={commander}, partner_enabled={partner_enabled}, secondary={sess.get('secondary_commander')}")
# Load theme pool size data and sort themes (R21 M1)
tags_raw = orch.tags_for_commander(commander)
recommended_raw = orch.recommended_tags_for_commander(commander)
tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(tags_raw, recommended_raw)
# R21 Enhancement: Section themes by pool size if enabled
tag_sections = []
recommended_sections = []
if THEME_POOL_SECTIONS:
tag_sections = _section_themes_by_pool_size(tags_sorted, pool_size)
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
context = { context = {
"request": request, "request": request,
"commander": {"name": commander}, "commander": {"name": commander},
"tags": tags, "tags": tags_sorted,
"recommended": orch.recommended_tags_for_commander(commander), "recommended": recommended_sorted,
"recommended_reasons": orch.recommended_tag_reasons_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 "",
@ -356,6 +466,10 @@ async def build_step2_get(request: Request) -> HTMLResponse:
# If there are no server-side tags for this commander, let the client clear any persisted ones # If there are no server-side tags for this commander, let the client clear any persisted ones
# to avoid themes sticking between fresh runs. # to avoid themes sticking between fresh runs.
"clear_persisted": False if selected else True, "clear_persisted": False if selected else True,
"pool_size": pool_size, # R21 M1: Pass pool size data to template
"use_sections": THEME_POOL_SECTIONS, # R21: Flag for template
"tag_sections": tag_sections, # R21: Sectioned themes
"recommended_sections": recommended_sections, # R21: Sectioned recommendations
} }
context.update( context.update(
_partner_ui_context( _partner_ui_context(
@ -375,7 +489,9 @@ async def build_step2_get(request: Request) -> HTMLResponse:
if partner_tags: if partner_tags:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
context["tags"] = partner_tags # Re-sort partner tags by pool size using helper (R21 M1)
partner_tags_sorted, _, _ = _prepare_step2_theme_data(partner_tags, [])
context["tags"] = partner_tags_sorted
# Deduplicate recommended tags: remove any that are already in partner_tags # Deduplicate recommended tags: remove any that are already in partner_tags
partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags}
original_recommended = context.get("recommended", []) original_recommended = context.get("recommended", [])
@ -383,12 +499,14 @@ async def build_step2_get(request: Request) -> HTMLResponse:
tag for tag in original_recommended tag for tag in original_recommended
if str(tag).strip().casefold() not in partner_tags_lower if str(tag).strip().casefold() not in partner_tags_lower
] ]
# Re-sort deduplicated recommended tags using helper (R21 M1)
dedup_sorted, _, _ = _prepare_step2_theme_data(deduplicated_recommended, [])
logger.info( logger.info(
f"Step2: partner_tags={len(partner_tags)}, " f"Step2: partner_tags={len(partner_tags)}, "
f"original_recommended={len(original_recommended)}, " f"original_recommended={len(original_recommended)}, "
f"deduplicated_recommended={len(deduplicated_recommended)}" f"deduplicated_recommended={len(deduplicated_recommended)}"
) )
context["recommended"] = deduplicated_recommended context["recommended"] = dedup_sorted
resp = templates.TemplateResponse("build/_step2.html", context) resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -436,11 +554,22 @@ async def build_step2_submit(
sel_br = None sel_br = None
if is_gc and (sel_br is None or sel_br < 3): if is_gc and (sel_br is None or sel_br < 3):
sel_br = 3 sel_br = 3
recommended_raw = orch.recommended_tags_for_commander(commander)
available_tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(available_tags, recommended_raw)
# R21: Section themes by pool size if enabled
tag_sections = []
recommended_sections = []
if THEME_POOL_SECTIONS:
tag_sections = _section_themes_by_pool_size(available_tags_sorted, pool_size)
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
context = { context = {
"request": request, "request": request,
"commander": {"name": commander}, "commander": {"name": commander},
"tags": available_tags, "tags": available_tags_sorted,
"recommended": orch.recommended_tags_for_commander(commander), "recommended": recommended_sorted,
"recommended_reasons": orch.recommended_tag_reasons_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.",
@ -450,6 +579,10 @@ async def build_step2_submit(
"selected_bracket": sel_br, "selected_bracket": sel_br,
"tag_mode": (tag_mode or "AND"), "tag_mode": (tag_mode or "AND"),
"gc_commander": is_gc, "gc_commander": is_gc,
"pool_size": pool_size,
"use_sections": THEME_POOL_SECTIONS,
"tag_sections": tag_sections,
"recommended_sections": recommended_sections,
} }
context.update( context.update(
_partner_ui_context( _partner_ui_context(
@ -467,7 +600,8 @@ async def build_step2_submit(
) )
partner_tags = context.pop("partner_theme_tags", None) partner_tags = context.pop("partner_theme_tags", None)
if partner_tags: if partner_tags:
context["tags"] = partner_tags partner_tags_sorted, _, _ = _prepare_step2_theme_data(partner_tags, [])
context["tags"] = partner_tags_sorted
resp = templates.TemplateResponse("build/_step2.html", context) resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp
@ -508,11 +642,22 @@ async def build_step2_submit(
sel_br = int(bracket) sel_br = int(bracket)
except Exception: except Exception:
sel_br = None sel_br = None
recommended_raw = orch.recommended_tags_for_commander(commander)
available_tags_sorted, recommended_sorted, pool_size = _prepare_step2_theme_data(available_tags, recommended_raw)
# R21: Section themes by pool size if enabled
tag_sections = []
recommended_sections = []
if THEME_POOL_SECTIONS:
tag_sections = _section_themes_by_pool_size(available_tags_sorted, pool_size)
recommended_sections = _section_themes_by_pool_size(recommended_sorted, pool_size)
context: dict[str, Any] = { context: dict[str, Any] = {
"request": request, "request": request,
"commander": {"name": commander}, "commander": {"name": commander},
"tags": available_tags, "tags": available_tags_sorted,
"recommended": orch.recommended_tags_for_commander(commander), "recommended": recommended_sorted,
"recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander),
"brackets": orch.bracket_options(), "brackets": orch.bracket_options(),
"primary_tag": primary_tag or "", "primary_tag": primary_tag or "",
@ -522,6 +667,10 @@ async def build_step2_submit(
"tag_mode": (tag_mode or "AND"), "tag_mode": (tag_mode or "AND"),
"gc_commander": is_gc, "gc_commander": is_gc,
"error": None, "error": None,
"pool_size": pool_size,
"use_sections": THEME_POOL_SECTIONS,
"tag_sections": tag_sections,
"recommended_sections": recommended_sections,
} }
context.update( context.update(
_partner_ui_context( _partner_ui_context(
@ -539,7 +688,8 @@ async def build_step2_submit(
) )
partner_tags = context.pop("partner_theme_tags", None) partner_tags = context.pop("partner_theme_tags", None)
if partner_tags: if partner_tags:
context["tags"] = partner_tags partner_tags_sorted, _, _ = _prepare_step2_theme_data(partner_tags, [])
context["tags"] = partner_tags_sorted
resp = templates.TemplateResponse("build/_step2.html", context) resp = templates.TemplateResponse("build/_step2.html", context)
resp.set_cookie("sid", sid, httponly=True, samesite="lax") resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp return resp

View file

@ -4934,6 +4934,21 @@ img.lqip.loaded {
color: #fff; color: #fff;
} }
/* Pool size badge for chip context (R21 M2) */
.badge-pool {
font-size: 10px;
color: #6b7280;
background: transparent;
font-weight: 400;
padding: 0;
margin-left: .15rem;
}
.chip.active .badge-pool {
color: #93c5fd;
/* lighter blue for active chips */
}
/* Legacy lifecycle quality badges (draft/reviewed/final) */ /* Legacy lifecycle quality badges (draft/reviewed/final) */
.badge-quality-draft { .badge-quality-draft {

View file

@ -2732,6 +2732,20 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
color: #fff; color: #fff;
} }
/* Pool size badge for chip context (R21 M2) */
.badge-pool {
font-size: 10px;
color: #6b7280;
background: transparent;
font-weight: 400;
padding: 0;
margin-left: .15rem;
}
.chip.active .badge-pool {
color: #93c5fd; /* lighter blue for active chips */
}
/* Legacy lifecycle quality badges (draft/reviewed/final) */ /* Legacy lifecycle quality badges (draft/reviewed/final) */
.badge-quality-draft { .badge-quality-draft {
background: #4338ca; background: #4338ca;

View file

@ -101,18 +101,53 @@
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Recommended</div> <div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Recommended</div>
<div id="modal-tag-reco" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;"> <div id="modal-tag-reco" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% if recommended and recommended|length %} {% if recommended and recommended|length %}
{# R21: Recommended themes (always flat, never sectioned) #}
{% for r in recommended %} {% for r in recommended %}
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %} {% 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" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button> {% set pool_count = pool_size.get(r|slugify, 0) if pool_size else 0 %}
<button type="button" class="chip chip-reco" data-tag="{{ r }}" data-pool-size="{{ pool_count }}" title="{{ tip }}">★ {{ r }} <span class="badge badge-pool" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ pool_count }}</span></button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button> <button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
</div> </div>
</div> </div>
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;"> {# R21 M3: Visual separator between recommended and general themes #}
{% for t in tags %} {% if recommended and recommended|length %}
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button> <hr style="margin:.75rem 0; border:none; border-top:1px solid var(--border-color, #333);" />
{% endfor %} <div style="display:flex; align-items:baseline; gap:.4rem; flex-wrap:wrap; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">All Available Themes</div>
<div class="muted" style="font-size:11px;" title="The number on each theme is the approximate number of eligible cards in that theme's pool for your commander's color identity. Sections: Vast (1000+), Large (500999), Moderate (200499), Small (50199), Tiny (&lt;50).">— badge = card pool size</div>
</div>
{% endif %}
{% if use_sections and tag_sections %}
{% set section_labels = [] %}
{% for section in tag_sections %}{% set _ = section_labels.append({'label': section.label, 'themes': section.themes}) %}{% endfor %}
{% endif %}
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;"
data-use-sections="{% if use_sections and tag_sections %}1{% else %}0{% endif %}"
data-pool-size='{{ pool_size | tojson if pool_size else "{}" }}'
data-sections='{{ tag_sections | tojson if (use_sections and tag_sections) else "[]" }}'>
{% if use_sections and tag_sections %}
{# R21: Sectioned general themes #}
{% for section in tag_sections %}
<div style="width:100%; margin-bottom:.5rem;">
{% set section_tip = {'Vast': '1000+ cards', 'Large': '500999 cards', 'Moderate': '200499 cards', 'Small': '50199 cards', 'Tiny': 'fewer than 50 cards'} %}
<div class="muted" style="font-size:11px; margin-bottom:.25rem;" title="{{ section.label }} pool: themes with approximately {{ section_tip.get(section.label, '') }} available for your commander.">{{ section.label }} Pool ({{ section.themes|length }})</div>
<div style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in section.themes %}
{% set pool_count = pool_size.get(t|slugify, 0) if pool_size else 0 %}
<button type="button" class="chip" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
{# R21: Flat sorted general themes #}
{% for t in tags %}
{% set pool_count = pool_size.get(t|slugify, 0) if pool_size else 0 %}
<button type="button" class="chip" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
{% endfor %}
{% endif %}
</div> </div>
{% else %} {% else %}
<p class="muted">No theme tags available for this commander.</p> <p class="muted">No theme tags available for this commander.</p>
@ -225,74 +260,163 @@
} }
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); }); document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
function makeChip(tag, isPartner, poolSizeMap) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip' + (isPartner ? ' partner-added' : '');
btn.dataset.tag = tag;
var poolCount = (poolSizeMap && poolSizeMap[tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')]) || 0;
btn.dataset.poolSize = poolCount;
btn.title = isPartner ? 'From combined partner themes' : ('Pool size: approximately ' + poolCount + ' cards available for this theme');
btn.innerHTML = tag + (poolCount ? ' <span class="badge badge-pool">' + poolCount + '</span>' : '');
return btn;
}
function updatePartnerTags(partnerTags){ function updatePartnerTags(partnerTags){
if (!list || !reco) return; if (!list || !reco) return;
// Only rebuild if there are actually partner tags to add
var hasPartnerTags = Array.isArray(partnerTags) && partnerTags.length > 0;
if (!hasPartnerTags) {
// No partner tags, leave DOM structure intact (preserves sections)
updateUI();
return;
}
var useSections = list.dataset.useSections === '1';
var poolSizeMap = {};
try { poolSizeMap = JSON.parse(list.dataset.poolSize || '{}'); } catch(_) {}
var sections = [];
try { sections = JSON.parse(list.dataset.sections || '[]'); } catch(_) {}
// Remove old partner-added chips from available list // Remove old partner-added chips from available list
Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); }); Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); });
// Deduplicate: remove partner tags from recommended section to avoid showing them twice // Deduplicate: remove partner tags from recommended section to avoid showing them twice
if (partnerTags && partnerTags.length > 0) { var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); });
var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); }); Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){ var tag = btn.dataset.tag || '';
var tag = btn.dataset.tag || ''; if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) { btn.remove();
btn.remove(); }
});
if (useSections && sections.length) {
// Section thresholds (must match Python _section_themes_by_pool_size)
var THRESHOLDS = [
{label: 'Vast', min: 1000, tip: '1000+ cards'},
{label: 'Large', min: 500, tip: '500\u2013999 cards'},
{label: 'Moderate', min: 200, tip: '200\u2013499 cards'},
{label: 'Small', min: 50, tip: '50\u2013199 cards'},
{label: 'Tiny', min: 0, tip: 'fewer than 50 cards'},
];
function slugify(str) { return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
function getSectionLabel(poolCount) {
for (var i = 0; i < THRESHOLDS.length; i++) {
if (poolCount >= THRESHOLDS[i].min) return THRESHOLDS[i].label;
}
return 'Tiny';
}
// Gather all existing original chips (keyed by lowercase tag)
var existingChips = {};
Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).forEach(function(b){
var tag = (b.dataset.tag || '').trim();
if (tag) existingChips[tag.toLowerCase()] = b;
});
// Collect all tags: primary (from sections) + partner (new), deduplicated
var allTagMap = {}; // lowercase -> {tag, element, isPartner}
sections.forEach(function(sec){
(sec.themes || []).forEach(function(t){
var key = t.toLowerCase();
if (!allTagMap[key]) allTagMap[key] = {tag: t, element: existingChips[key] || null, isPartner: false};
});
});
partnerTags.forEach(function(t){
var value = String(t || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (!allTagMap[key]) allTagMap[key] = {tag: value, element: null, isPartner: true};
});
// Bucket all tags into sections by pool size
var sectionBuckets = {};
THRESHOLDS.forEach(function(s){ sectionBuckets[s.label] = []; });
Object.keys(allTagMap).forEach(function(key){
var item = allTagMap[key];
var poolCount = poolSizeMap[slugify(item.tag)] || 0;
var label = getSectionLabel(poolCount);
sectionBuckets[label].push({tag: item.tag, poolCount: poolCount, element: item.element, isPartner: item.isPartner});
});
// Sort each bucket by pool size desc, then alphabetically
THRESHOLDS.forEach(function(s){
sectionBuckets[s.label].sort(function(a, b){
if (b.poolCount !== a.poolCount) return b.poolCount - a.poolCount;
return a.tag.localeCompare(b.tag);
});
});
list.innerHTML = '';
THRESHOLDS.forEach(function(s){
var bucket = sectionBuckets[s.label];
if (!bucket.length) return;
var secDiv = document.createElement('div');
secDiv.style.cssText = 'width:100%; margin-bottom:.5rem;';
var labelDiv = document.createElement('div');
labelDiv.className = 'muted';
labelDiv.style.cssText = 'font-size:11px; margin-bottom:.25rem;';
labelDiv.title = s.label + ' pool: themes with approximately ' + s.tip + ' available for your commander.';
labelDiv.textContent = s.label + ' Pool (' + bucket.length + ')';
secDiv.appendChild(labelDiv);
var innerDiv = document.createElement('div');
innerDiv.style.cssText = 'display:flex; gap:.35rem; flex-wrap:wrap;';
bucket.forEach(function(item){
if (item.element) {
innerDiv.appendChild(item.element);
} else {
innerDiv.appendChild(makeChip(item.tag, item.isPartner, poolSizeMap));
}
});
secDiv.appendChild(innerDiv);
list.appendChild(secDiv);
});
} else {
// Flat mode: get existing tags, merge partner tags, sort, re-render
var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){
return {
element: b,
tag: (b.dataset.tag || '').trim(),
tagLower: (b.dataset.tag || '').trim().toLowerCase()
};
});
var combined = [];
var seen = new Set();
existingTags.forEach(function(item){
if (!item.tag || seen.has(item.tagLower)) return;
seen.add(item.tagLower);
combined.push({ tag: item.tag, element: item.element, isPartner: false });
});
partnerTags.forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
combined.push({ tag: value, element: null, isPartner: true });
});
combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); });
list.innerHTML = '';
combined.forEach(function(item){
if (item.element) {
list.appendChild(item.element);
} else {
list.appendChild(makeChip(item.tag, true, poolSizeMap));
} }
}); });
} }
// Get existing tags from the available list (original server-rendered ones)
var existingTags = Array.from(list.querySelectorAll('button.chip:not(.partner-added)')).map(function(b){
return {
element: b,
tag: (b.dataset.tag || '').trim(),
tagLower: (b.dataset.tag || '').trim().toLowerCase()
};
});
// Build combined list: existing + new partner tags
var combined = [];
var seen = new Set();
// Add existing tags first
existingTags.forEach(function(item){
if (!item.tag || seen.has(item.tagLower)) return;
seen.add(item.tagLower);
combined.push({ tag: item.tag, element: item.element, isPartner: false });
});
// Add new partner tags
(Array.isArray(partnerTags) ? partnerTags : []).forEach(function(tag){
var value = String(tag || '').trim();
if (!value) return;
var key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
combined.push({ tag: value, element: null, isPartner: true });
});
// Sort alphabetically
combined.sort(function(a, b){ return a.tag.localeCompare(b.tag); });
// Re-render the list in sorted order
list.innerHTML = '';
combined.forEach(function(item){
if (item.element) {
// Re-append existing element
list.appendChild(item.element);
} else {
// Create new partner-added chip
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chip partner-added';
btn.dataset.tag = item.tag;
btn.title = 'From combined partner themes';
btn.textContent = item.tag;
list.appendChild(btn);
}
});
// Update visibility of recommended section // Update visibility of recommended section
var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0; var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0;
if (recoBlock){ if (recoBlock){

View file

@ -104,7 +104,7 @@
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div> <div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
<div id="tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}> <div id="tag-reco-block" data-has-reco="{% if recommended and recommended|length %}1{% else %}0{% endif %}" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>
<div id="tag-reco-header" style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;"> <div id="tag-reco-header" style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
<div class="muted" style="font-size:12px;">Recommended</div> <div class="muted" style="font-size:13px; font-weight:600;">Recommended</div>
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Why?</button> <button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Why?</button>
</div> </div>
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" data-default-reasons='{{ (recommended_reasons or {}) | tojson }}' 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 id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" data-default-reasons='{{ (recommended_reasons or {}) | tojson }}' 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);">
@ -120,20 +120,48 @@
</div> </div>
<div id="tag-reco-list" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;"> <div id="tag-reco-list" aria-label="Recommended themes" data-original-tags='{{ (recommended or []) | tojson }}' style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
{% if recommended and recommended|length %} {% if recommended and recommended|length %}
{# R21: Recommended themes (always flat, never sectioned) #}
{% for r in recommended %} {% for r in recommended %}
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %} {% 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') %} {% 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> {% set pool_count = pool_size.get(r|slugify, 0) %}
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" data-pool-size="{{ pool_count }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }} <span class="badge badge-pool" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ pool_count }}</span></button>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button> <button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3" {% if not (recommended and recommended|length) %}style="display:none;"{% endif %}>Select all</button>
</div> </div>
</div> </div>
{% if recommended and recommended|length %}
<hr style="border:none; border-top:1px solid var(--border); margin:.75rem 0 .5rem 0;" />
<div style="display:flex; align-items:baseline; gap:.4rem; flex-wrap:wrap; margin-bottom:.35rem;">
<div class="muted" style="font-size:13px; font-weight:600;">All Available Themes</div>
<div class="muted" style="font-size:11px;" title="The number on each theme is the approximate number of eligible cards in that theme's pool for your commander's color identity. Sections: Vast (1000+), Large (500999), Moderate (200499), Small (50199), Tiny (&lt;50).">— badge = card pool size</div>
</div>
{% endif %}
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;"> <div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in tags %} {% if use_sections and tag_sections %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %} {# R21: Sectioned general themes #}
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button> {% for section in tag_sections %}
{% endfor %} <div style="width:100%; margin-bottom:.5rem;">
{% set section_tip = {'Vast': '1000+ cards', 'Large': '500999 cards', 'Moderate': '200499 cards', 'Small': '50199 cards', 'Tiny': 'fewer than 50 cards'} %}
<div class="muted" style="font-size:11px; margin-bottom:.25rem;" title="{{ section.label }} pool: themes with approximately {{ section_tip.get(section.label, '') }} available for your commander.">{{ section.label }} Pool ({{ section.themes|length }})</div>
<div style="display:flex; gap:.35rem; flex-wrap:wrap;">
{% for t in section.themes %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
{% set pool_count = pool_size.get(t|slugify, 0) %}
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
{# R21: Flat sorted general themes #}
{% for t in tags %}
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
{% set pool_count = pool_size.get(t|slugify, 0) %}
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" data-pool-size="{{ pool_count }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}" title="Pool size: approximately {{ pool_count }} cards available for this theme">{{ t }} <span class="badge badge-pool">{{ pool_count }}</span></button>
{% endfor %}
{% endif %}
</div> </div>
{% else %} {% else %}
<p>No theme tags available for this commander.</p> <p>No theme tags available for this commander.</p>

View file

@ -28,6 +28,7 @@ services:
SHOW_THEME_POOL_BADGES: "1" # 1=show pool size badges in theme catalog; 0=hide SHOW_THEME_POOL_BADGES: "1" # 1=show pool size badges in theme catalog; 0=hide
SHOW_THEME_POPULARITY_BADGES: "1" # 1=show popularity badges in theme catalog; 0=hide SHOW_THEME_POPULARITY_BADGES: "1" # 1=show popularity badges in theme catalog; 0=hide
SHOW_THEME_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide SHOW_THEME_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide
THEME_POOL_SECTIONS: "1" # 1=group themes by pool size (Vast/Large/Moderate/Small/Tiny); 0=flat sorted list
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5 WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable 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_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)

View file

@ -30,6 +30,7 @@ services:
SHOW_THEME_POOL_BADGES: "1" # 1=show pool size badges in theme catalog; 0=hide SHOW_THEME_POOL_BADGES: "1" # 1=show pool size badges in theme catalog; 0=hide
SHOW_THEME_POPULARITY_BADGES: "1" # 1=show popularity badges in theme catalog; 0=hide SHOW_THEME_POPULARITY_BADGES: "1" # 1=show popularity badges in theme catalog; 0=hide
SHOW_THEME_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide SHOW_THEME_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide
THEME_POOL_SECTIONS: "1" # 1=group themes by pool size (Vast/Large/Moderate/Small/Tiny); 0=flat sorted list
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5 WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable 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_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)