From b2b7796fb3b48f303131398f9a92ddaf11ec154a Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Mar 2026 11:42:44 -0700 Subject: [PATCH] feat: add pool size badges, sorting, and optional sections to theme picker --- .env.example | 1 + CHANGELOG.md | 9 + RELEASE_NOTES_TEMPLATE.md | 9 + code/web/app.py | 5 + code/web/routes/build_newflow.py | 28 ++- code/web/routes/build_wizard.py | 180 ++++++++++++-- code/web/static/styles.css | 15 ++ code/web/static/tailwind.css | 14 ++ code/web/templates/build/_new_deck_tags.html | 248 ++++++++++++++----- code/web/templates/build/_step2.html | 40 ++- docker-compose.yml | 1 + dockerhub-docker-compose.yml | 1 + 12 files changed, 465 insertions(+), 86 deletions(-) diff --git a/.env.example b/.env.example index 1ca904c..6b97ea2 100644 --- a/.env.example +++ b/.env.example @@ -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_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) +# 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_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 956cda3..a784266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### 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` - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) - **Catalog Statistics**: Total themes, average quality score displayed prominently diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 4704bdb..ce53c76 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -2,6 +2,15 @@ ## [Unreleased] ### 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` - **Quality Distribution**: Visual breakdown of theme counts by tier (Excellent/Good/Fair/Poor) - **Catalog Statistics**: Total themes, average quality score displayed prominently diff --git a/code/web/app.py b/code/web/app.py index d6ea64f..20013ed 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -130,6 +130,10 @@ def card_image_url(card_name: str, size: str = "normal") -> str: 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, ...}) # and reorder to the new signature TemplateResponse(request, name, {...}). # 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_POPULARITY_BADGES = _as_bool(os.getenv("SHOW_THEME_POPULARITY_BADGES"), 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' ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), True) ENABLE_BATCH_BUILD = _as_bool(os.getenv("ENABLE_BATCH_BUILD"), True) diff --git a/code/web/routes/build_newflow.py b/code/web/routes/build_newflow.py index 13d1592..e270a7e 100644 --- a/code/web/routes/build_newflow.py +++ b/code/web/routes/build_newflow.py @@ -19,6 +19,7 @@ from ..app import ( WEB_IDEALS_UI, ENABLE_BATCH_BUILD, DEFAULT_THEME_MATCH_MODE, + THEME_POOL_SECTIONS, ) from ..services.build_utils import ( step5_ctx_from_result, @@ -36,6 +37,7 @@ from .build_partners import ( _partner_ui_context, _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 router = APIRouter() @@ -173,9 +175,20 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes info = orch.commander_select(name) if not info.get("ok"): return HTMLResponse(f'
Commander not found: {name}
') - tags = orch.tags_for_commander(info["name"]) or [] - recommended = orch.recommended_tags_for_commander(info["name"]) if tags else [] - recommended_reasons = orch.recommended_tag_reasons_for_commander(info["name"]) if tags else {} + tags_raw = orch.tags_for_commander(info["name"]) or [] + 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_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"]) # Render tags slot content and OOB commander preview simultaneously # 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}, "tags": tags, "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, "gc_commander": is_gc, "brackets": orch.bracket_options(), } + ctx.update( _partner_ui_context( info["name"], @@ -220,6 +238,10 @@ async def build_new_inspect(request: Request, name: str = Query(...)) -> HTMLRes seen.add(key) merged_tags.append(token) 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 partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} diff --git a/code/web/routes/build_wizard.py b/code/web/routes/build_wizard.py index 0951dfe..e6283db 100644 --- a/code/web/routes/build_wizard.py +++ b/code/web/routes/build_wizard.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Request, Form, Query from fastapi.responses import HTMLResponse 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 ( step5_base_ctx, step5_ctx_from_result, @@ -30,6 +30,7 @@ from ..services.build_utils import ( ) from ..services import orchestrator as orch 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 ..services.combo_utils import detect_all as _detect_all 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: """Get current builder's deck summary.""" try: @@ -148,16 +214,32 @@ async def build_step1_search( sess["last_step"] = 2 commander_name = res.get("name") 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 = { "request": request, "commander": res, - "tags": orch.tags_for_commander(commander_name), - "recommended": orch.recommended_tags_for_commander(commander_name), + "tags": tags_sorted, + "recommended": recommended_sorted, "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander_name), "brackets": orch.bracket_options(), "gc_commander": gc_flag, "selected_bracket": (3 if gc_flag else None), "clear_persisted": True, + "pool_size": pool_size, + "use_sections": THEME_POOL_SECTIONS, + "tag_sections": tag_sections, + "recommended_sections": recommended_sections, } context.update( _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', [])) except Exception: 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 = { "request": request, "commander": res, - "tags": orch.tags_for_commander(res["name"]), - "recommended": orch.recommended_tags_for_commander(res["name"]), + "tags": tags_sorted, + "recommended": recommended_sorted, "recommended_reasons": orch.recommended_tag_reasons_for_commander(res["name"]), "brackets": orch.bracket_options(), "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, # so the Step 2 UI should clear any localStorage theme persistence. "clear_persisted": True, + "pool_size": pool_size, + "use_sections": THEME_POOL_SECTIONS, + "tag_sections": tag_sections, + "recommended_sections": recommended_sections, } context.update( _partner_ui_context( @@ -340,11 +438,23 @@ async def build_step2_get(request: Request) -> HTMLResponse: logger = logging.getLogger(__name__) 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 = { "request": request, "commander": {"name": commander}, - "tags": tags, - "recommended": orch.recommended_tags_for_commander(commander), + "tags": tags_sorted, + "recommended": recommended_sorted, "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "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 # to avoid themes sticking between fresh runs. "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( _partner_ui_context( @@ -375,7 +489,9 @@ async def build_step2_get(request: Request) -> HTMLResponse: if partner_tags: import logging 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 partner_tags_lower = {str(tag).strip().casefold() for tag in partner_tags} original_recommended = context.get("recommended", []) @@ -383,12 +499,14 @@ async def build_step2_get(request: Request) -> HTMLResponse: tag for tag in original_recommended 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( f"Step2: partner_tags={len(partner_tags)}, " f"original_recommended={len(original_recommended)}, " f"deduplicated_recommended={len(deduplicated_recommended)}" ) - context["recommended"] = deduplicated_recommended + context["recommended"] = dedup_sorted resp = templates.TemplateResponse("build/_step2.html", context) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -436,11 +554,22 @@ async def build_step2_submit( sel_br = None if is_gc and (sel_br is None or 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 = { "request": request, "commander": {"name": commander}, - "tags": available_tags, - "recommended": orch.recommended_tags_for_commander(commander), + "tags": available_tags_sorted, + "recommended": recommended_sorted, "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "error": "Please choose a primary theme.", @@ -450,6 +579,10 @@ async def build_step2_submit( "selected_bracket": sel_br, "tag_mode": (tag_mode or "AND"), "gc_commander": is_gc, + "pool_size": pool_size, + "use_sections": THEME_POOL_SECTIONS, + "tag_sections": tag_sections, + "recommended_sections": recommended_sections, } context.update( _partner_ui_context( @@ -467,7 +600,8 @@ async def build_step2_submit( ) partner_tags = context.pop("partner_theme_tags", None) 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.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -508,11 +642,22 @@ async def build_step2_submit( sel_br = int(bracket) except Exception: 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] = { "request": request, "commander": {"name": commander}, - "tags": available_tags, - "recommended": orch.recommended_tags_for_commander(commander), + "tags": available_tags_sorted, + "recommended": recommended_sorted, "recommended_reasons": orch.recommended_tag_reasons_for_commander(commander), "brackets": orch.bracket_options(), "primary_tag": primary_tag or "", @@ -522,6 +667,10 @@ async def build_step2_submit( "tag_mode": (tag_mode or "AND"), "gc_commander": is_gc, "error": None, + "pool_size": pool_size, + "use_sections": THEME_POOL_SECTIONS, + "tag_sections": tag_sections, + "recommended_sections": recommended_sections, } context.update( _partner_ui_context( @@ -539,7 +688,8 @@ async def build_step2_submit( ) partner_tags = context.pop("partner_theme_tags", None) 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.set_cookie("sid", sid, httponly=True, samesite="lax") return resp diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 0b164db..4aa1b9e 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -4934,6 +4934,21 @@ img.lqip.loaded { 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) */ .badge-quality-draft { diff --git a/code/web/static/tailwind.css b/code/web/static/tailwind.css index b357485..b2ab404 100644 --- a/code/web/static/tailwind.css +++ b/code/web/static/tailwind.css @@ -2732,6 +2732,20 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } 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) */ .badge-quality-draft { background: #4338ca; diff --git a/code/web/templates/build/_new_deck_tags.html b/code/web/templates/build/_new_deck_tags.html index 7afd820..ead69c5 100644 --- a/code/web/templates/build/_new_deck_tags.html +++ b/code/web/templates/build/_new_deck_tags.html @@ -101,18 +101,53 @@
Recommended
-