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
{% if recommended and recommended|length %}
+ {# R21: Recommended themes (always flat, never sectioned) #}
{% 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 pool_count = pool_size.get(r|slugify, 0) if pool_size else 0 %}
+
{% endfor %}
{% endif %}
-
- {% for t in tags %}
-
- {% endfor %}
+ {# R21 M3: Visual separator between recommended and general themes #}
+ {% if recommended and recommended|length %}
+
+
+
All Available Themes
+
— badge = card pool size
+
+ {% 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 %}
+
+ {% if use_sections and tag_sections %}
+ {# R21: Sectioned general themes #}
+ {% for section in tag_sections %}
+
{{ section.label }} Pool ({{ section.themes|length }})
+
+ {% for t in section.themes %}
+ {% set pool_count = pool_size.get(t|slugify, 0) if pool_size else 0 %}
+
+ {% endfor %}
+
+
+ {% 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 %}
+
+ {% endfor %}
+ {% endif %}
{% else %}
No theme tags available for this commander.
@@ -225,74 +260,163 @@
}
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 ? ' ' + poolCount + '' : '');
+ return btn;
+ }
+
function updatePartnerTags(partnerTags){
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
Array.from(list.querySelectorAll('button.partner-added')).forEach(function(btn){ btn.remove(); });
// 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(); });
- Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
- var tag = btn.dataset.tag || '';
- if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
- btn.remove();
+ var partnerTagsLower = partnerTags.map(function(t){ return String(t || '').trim().toLowerCase(); });
+ Array.from(reco.querySelectorAll('button.chip-reco:not(.partner-original)')).forEach(function(btn){
+ var tag = btn.dataset.tag || '';
+ if (partnerTagsLower.indexOf(tag.toLowerCase()) >= 0) {
+ 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
var hasAnyReco = reco.querySelectorAll('button.chip-reco').length > 0;
if (recoBlock){
diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html
index 0186eaa..cab0a6a 100644
--- a/code/web/templates/build/_step2.html
+++ b/code/web/templates/build/_step2.html
@@ -104,7 +104,7 @@
-
Recommended
+
⭐ Recommended
@@ -120,20 +120,48 @@
{% if recommended and recommended|length %}
+ {# R21: Recommended themes (always flat, never sectioned) #}
{% 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') %}
-
+ {% set pool_count = pool_size.get(r|slugify, 0) %}
+
{% endfor %}
{% endif %}
+ {% if recommended and recommended|length %}
+
+
+
All Available Themes
+
— badge = card pool size
+
+ {% endif %}
- {% for t in tags %}
- {% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
-
- {% endfor %}
+ {% if use_sections and tag_sections %}
+ {# R21: Sectioned general themes #}
+ {% for section in tag_sections %}
+
{{ section.label }} Pool ({{ section.themes|length }})
+
+ {% 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) %}
+
+ {% endfor %}
+
+
+ {% 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) %}
+
+ {% endfor %}
+ {% endif %}
{% else %}
No theme tags available for this commander.
diff --git a/docker-compose.yml b/docker-compose.yml
index e4a8df2..a69b5a5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,6 +28,7 @@ services:
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_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
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)
diff --git a/dockerhub-docker-compose.yml b/dockerhub-docker-compose.yml
index b0aca51..2431894 100644
--- a/dockerhub-docker-compose.yml
+++ b/dockerhub-docker-compose.yml
@@ -30,6 +30,7 @@ services:
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_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
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)