feat: add theme quality, pool size, and popularity badges with filtering (#56)

This commit is contained in:
mwisnowski 2026-03-20 09:03:20 -07:00 committed by GitHub
parent 03e2846882
commit 8efdc77c08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1165 additions and 64 deletions

View file

@ -47,6 +47,10 @@ SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1"
ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="1" ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="1"
ENABLE_CUSTOM_THEMES=1 # dockerhub: ENABLE_CUSTOM_THEMES="1" ENABLE_CUSTOM_THEMES=1 # dockerhub: ENABLE_CUSTOM_THEMES="1"
USER_THEME_LIMIT=8 # dockerhub: USER_THEME_LIMIT="8" USER_THEME_LIMIT=8 # dockerhub: USER_THEME_LIMIT="8"
SHOW_THEME_QUALITY_BADGES=1 # dockerhub: SHOW_THEME_QUALITY_BADGES="1" (show quality 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_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog)
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,44 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
## [Unreleased] ## [Unreleased]
### Added ### Added
- **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
- **Top 10 Highest Quality**: Best-curated themes with links to theme pages
- **Bottom 10 Lowest Quality**: Themes needing improvement with actionable suggestions
- **Improvement Tools**: Direct links to linter CLI command and editorial documentation
- **Protected Access**: Dashboard gated behind SHOW_DIAGNOSTICS=1 flag for admin use
- **Main Diagnostics Integration**: Quality stats preview card on main diagnostics page with link to full dashboard
- **Theme Badge Explanations**: Detailed reasoning for quality, pool size, and popularity badges on individual theme pages
- **Quality Explanations**: Multi-factor breakdown showing synergy breakdown (curated/enforced/inferred counts), deck archetype classification, description curation status, and editorial quality status
- **Pool Size Explanations**: Card count with contextual guidance on flexibility and optimization potential
- **Popularity Explanations**: Adoption pattern descriptions explaining why themes have their popularity tier
- **Collapsible Display**: Badge details in collapsible section (open by default), matching catalog page badge legend pattern
- **Feature Flag Respects**: Explanations only show for enabled badge types (respects SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES)
- **Dynamic Reasoning**: Explanations generated based on actual theme data (quality score, synergy counts, editorial status, archetype metadata)
- **Theme Catalog Badge System**: Comprehensive metric visualization with granular display control
- **Quality Badges**: Editorial quality indicators (Excellent/Good/Fair/Poor) with semantic colors
- **Pool Size Badges**: Card availability indicators (Vast/Large/Moderate/Small/Tiny) showing total cards per theme
- **Popularity Badges**: Usage frequency indicators (Very Common/Common/Uncommon/Niche/Rare) based on theme adoption
- **Badge Feature Flags**: Individual toggle flags for each badge type (SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES)
- **Filter Controls**: Dropdown filters and quick-select chips for all three metrics with master toggle (SHOW_THEME_FILTERS)
- **Theme Pool Size Display**: Visual indicators showing total card availability per theme
- **Pool Size Calculation**: Automatic counting of cards with each theme tag from parquet data
- **Pool Tier Badges**: Color-coded badges (Vast/Large/Moderate/Small/Tiny) showing pool size categories
- **Pool Data in API**: Theme pool size (card count) and tier included in all theme API responses
- **Pool Badges CSS**: New badge styles with distinct colors (violet/teal/cyan/orange/gray for pool tiers)
- **Dual Metric System**: Quality badges (editorial completeness) + Pool size badges (card availability) shown together
- **Theme Quality Score Display**: Visual quality indicators in web UI for theme catalog
- **Quality Tier Badges**: Color-coded badges (Excellent/Good/Fair/Poor) shown in theme lists and detail pages
- **Quality Scoring**: Automatic calculation during theme loading based on completeness, uniqueness, and curation quality
- **Quality Data in API**: Theme quality tier and normalized score (0.0-1.0) included in all theme API responses
- **Quality Badges CSS**: New badge styles with semantic colors (green/blue/yellow/red for quality tiers)
- **Theme Catalog Filtering**: Advanced filtering system for quality, pool size, and popularity
- **Filter Dropdowns**: Select-based filters for precise tier selection (Quality: E/G/F/P, Pool: V/L/M/S/T, Popularity: VC/C/U/N/R)
- **Quick Filter Chips**: Single-click filter activation with letter-based shortcuts
- **Combined Filtering**: Multiple filter types work together with AND logic (e.g., Good quality + Vast pool + Common popularity)
- **Active Filter Display**: Visual chips showing applied filters with individual remove buttons
- **Filter Performance**: Backend filtering in both fast path (theme_list.json) and fallback (full index) with sub-200ms response times
- **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation - **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation
- **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override) - **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override)
- **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability - **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability

View file

@ -250,6 +250,10 @@ See `.env.example` for the full catalog. Common knobs:
| `SHOW_DIAGNOSTICS` | `1` | Enable Diagnostics tools and overlays. | | `SHOW_DIAGNOSTICS` | `1` | Enable Diagnostics tools and overlays. |
| `SHOW_COMMANDERS` | `1` | Expose the commander browser. | | `SHOW_COMMANDERS` | `1` | Expose the commander browser. |
| `ENABLE_THEMES` | `1` | Keep the theme selector and themes explorer visible. | | `ENABLE_THEMES` | `1` | Keep the theme selector and themes explorer visible. |
| `SHOW_THEME_QUALITY_BADGES` | `1` | Show quality badges in theme catalog (editorial quality score). |
| `SHOW_THEME_POOL_BADGES` | `1` | Show pool size badges in theme catalog (total available cards). |
| `SHOW_THEME_POPULARITY_BADGES` | `1` | Show popularity badges in theme catalog (usage frequency). |
| `SHOW_THEME_FILTERS` | `1` | Show filter dropdowns and quick chips in theme catalog. |
| `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. | | `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. |
| `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. | | `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. |
| `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |

View file

@ -275,6 +275,10 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
| `SHOW_COMMANDERS` | `1` | Enable the commander browser. | | `SHOW_COMMANDERS` | `1` | Enable the commander browser. |
| `ENABLE_THEMES` | `1` | Keep the theme browser and selector active. | | `ENABLE_THEMES` | `1` | Keep the theme browser and selector active. |
| `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. | | `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. |
| `SHOW_THEME_QUALITY_BADGES` | `1` | Show quality badges in theme catalog (editorial quality score). |
| `SHOW_THEME_POOL_BADGES` | `1` | Show pool size badges in theme catalog (total available cards). |
| `SHOW_THEME_POPULARITY_BADGES` | `1` | Show popularity badges in theme catalog (usage frequency). |
| `SHOW_THEME_FILTERS` | `1` | Show filter dropdowns and quick chips in theme catalog. |
| `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. | | `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. |
| `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. | | `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. |
| `SHOW_MUST_HAVE_BUTTONS` | `0` | Reveal the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `SHOW_MUST_HAVE_BUTTONS` | `0` | Reveal the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). |

View file

@ -2,6 +2,44 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- **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
- **Top 10 Highest Quality**: Best-curated themes with links to theme pages
- **Bottom 10 Lowest Quality**: Themes needing improvement with actionable suggestions
- **Improvement Tools**: Direct links to linter CLI command and editorial documentation
- **Protected Access**: Dashboard gated behind SHOW_DIAGNOSTICS=1 flag for admin use
- **Main Diagnostics Integration**: Quality stats preview card on main diagnostics page with link to full dashboard
- **Theme Badge Explanations**: Detailed reasoning for quality, pool size, and popularity badges on individual theme pages
- **Quality Explanations**: Multi-factor breakdown showing synergy breakdown (curated/enforced/inferred counts), deck archetype classification, description curation status, and editorial quality status
- **Pool Size Explanations**: Card count with contextual guidance on flexibility and optimization potential
- **Popularity Explanations**: Adoption pattern descriptions explaining why themes have their popularity tier
- **Collapsible Display**: Badge details in collapsible section (open by default), matching catalog page badge legend pattern
- **Feature Flag Respects**: Explanations only show for enabled badge types (respects SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES)
- **Dynamic Reasoning**: Explanations generated based on actual theme data (quality score, synergy counts, editorial status, archetype metadata)
- **Theme Catalog Badge System**: Comprehensive metric visualization with granular display control
- **Quality Badges**: Editorial quality indicators (Excellent/Good/Fair/Poor) with semantic colors
- **Pool Size Badges**: Card availability indicators (Vast/Large/Moderate/Small/Tiny) showing total cards per theme
- **Popularity Badges**: Usage frequency indicators (Very Common/Common/Uncommon/Niche/Rare) based on theme adoption
- **Badge Feature Flags**: Individual toggle flags for each badge type (SHOW_THEME_QUALITY_BADGES, SHOW_THEME_POOL_BADGES, SHOW_THEME_POPULARITY_BADGES)
- **Filter Controls**: Dropdown filters and quick-select chips for all three metrics with master toggle (SHOW_THEME_FILTERS)
- **Theme Pool Size Display**: Visual indicators showing total card availability per theme
- **Pool Size Calculation**: Automatic counting of cards with each theme tag from parquet data
- **Pool Tier Badges**: Color-coded badges (Vast/Large/Moderate/Small/Tiny) showing pool size categories
- **Pool Data in API**: Theme pool size (card count) and tier included in all theme API responses
- **Pool Badges CSS**: New badge styles with distinct colors (violet/teal/cyan/orange/gray for pool tiers)
- **Dual Metric System**: Quality badges (editorial completeness) + Pool size badges (card availability) shown together
- **Theme Quality Score Display**: Visual quality indicators in web UI for theme catalog
- **Quality Tier Badges**: Color-coded badges (Excellent/Good/Fair/Poor) shown in theme lists and detail pages
- **Quality Scoring**: Automatic calculation during theme loading based on completeness, uniqueness, and curation quality
- **Quality Data in API**: Theme quality tier and normalized score (0.0-1.0) included in all theme API responses
- **Quality Badges CSS**: New badge styles with semantic colors (green/blue/yellow/red for quality tiers)
- **Theme Catalog Filtering**: Advanced filtering system for quality, pool size, and popularity
- **Filter Dropdowns**: Select-based filters for precise tier selection (Quality: E/G/F/P, Pool: V/L/M/S/T, Popularity: VC/C/U/N/R)
- **Quick Filter Chips**: Single-click filter activation with letter-based shortcuts
- **Combined Filtering**: Multiple filter types work together with AND logic (e.g., Good quality + Vast pool + Common popularity)
- **Active Filter Display**: Visual chips showing applied filters with individual remove buttons
- **Filter Performance**: Backend filtering in both fast path (theme_list.json) and fallback (full index) with sub-200ms response times
- **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation - **Theme Editorial Quality & Standards**: Complete editorial system for theme catalog curation
- **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override) - **Editorial Metadata Fields**: `description_source` (tracks provenance: official/inferred/custom) and `popularity_pinned` (manual tier override)
- **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability - **Heuristics Externalization**: Theme classification rules moved to `config/themes/editorial_heuristics.yml` for maintainability
@ -23,7 +61,7 @@
- Eliminated manual JSON stripping step (JSON is derived artifact, not source of truth) - Eliminated manual JSON stripping step (JSON is derived artifact, not source of truth)
- **Parquet Theme Stripping**: Strip low-card themes directly from card data files - **Parquet Theme Stripping**: Strip low-card themes directly from card data files
- Added `strip_parquet_themes.py` script with dry-run, verbose, and backup modes - Added `strip_parquet_themes.py` script with dry-run, verbose, and backup modes
- Added parquet manipulation functions to `theme_stripper.py`: backup, filter, update, and strip operations - Added parquet manipulation functions to `theme_stripper.py`: `backup_parquet_file()`, `filter_theme_tags()`, `update_parquet_theme_tags()`, `strip_parquet_themes()`
- Handles multiple themeTags formats: numpy arrays, lists, and comma/pipe-separated strings - Handles multiple themeTags formats: numpy arrays, lists, and comma/pipe-separated strings
- Stripped 97 theme tag occurrences from 30,674 cards in `all_cards.parquet` - Stripped 97 theme tag occurrences from 30,674 cards in `all_cards.parquet`
- Updated `stripped_themes.yml` log with 520 themes stripped from parquet source - Updated `stripped_themes.yml` log with 520 themes stripped from parquet source

View file

@ -59,6 +59,16 @@ class ThemeEntry(BaseModel):
None, None,
description="Lifecycle quality flag (draft|reviewed|final); optional and not yet enforced strictly", description="Lifecycle quality flag (draft|reviewed|final); optional and not yet enforced strictly",
) )
quality_tier: Optional[str] = Field(
None,
description="Editorial quality tier (Excellent|Good|Fair|Poor) calculated from theme completeness metrics (R20)",
)
quality_score: Optional[float] = Field(
None,
ge=0.0,
le=1.0,
description="Normalized quality score (0.0-1.0) based on example cards, uniqueness, description source (R20)",
)
model_config = ConfigDict(extra='forbid') model_config = ConfigDict(extra='forbid')

View file

@ -175,6 +175,10 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False) SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True)
SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_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_FILTERS = _as_bool(os.getenv("SHOW_THEME_FILTERS"), True)
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)
@ -314,6 +318,10 @@ templates.env.globals.update({
"enable_partner_mechanics": ENABLE_PARTNER_MECHANICS, "enable_partner_mechanics": ENABLE_PARTNER_MECHANICS,
"allow_must_haves": ALLOW_MUST_HAVES, "allow_must_haves": ALLOW_MUST_HAVES,
"show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS,
"show_theme_quality_badges": SHOW_THEME_QUALITY_BADGES,
"show_theme_pool_badges": SHOW_THEME_POOL_BADGES,
"show_theme_popularity_badges": SHOW_THEME_POPULARITY_BADGES,
"show_theme_filters": SHOW_THEME_FILTERS,
"default_theme": DEFAULT_THEME, "default_theme": DEFAULT_THEME,
"random_modes": RANDOM_MODES, "random_modes": RANDOM_MODES,
"random_ui": RANDOM_UI, "random_ui": RANDOM_UI,
@ -2531,7 +2539,34 @@ async def diagnostics_home(request: Request) -> HTMLResponse:
summary["colors"] = {} summary["colors"] = {}
except Exception: except Exception:
summary = {"updated_at": None, "colors": {}} summary = {"updated_at": None, "colors": {}}
ctx = {"request": request, "merge_summary": summary}
# Calculate quality statistics for preview
quality_stats = None
try:
from .services.theme_catalog_loader import load_index as _load_idx
idx = _load_idx()
total_themes = len(idx.catalog.themes)
tier_counts = {'Excellent': 0, 'Good': 0, 'Fair': 0, 'Poor': 0}
quality_scores = []
for slug, tier in idx.quality_tier_by_slug.items():
if tier:
tier_counts[tier] = tier_counts.get(tier, 0) + 1
score = idx.quality_score_by_slug.get(slug)
if score is not None:
quality_scores.append(score)
avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0
quality_stats = {
'total_themes': total_themes,
'tier_counts': tier_counts,
'avg_quality_score': avg_quality_score,
}
except Exception:
pass
ctx = {"request": request, "merge_summary": summary, "quality_stats": quality_stats}
return templates.TemplateResponse("diagnostics/index.html", ctx) return templates.TemplateResponse("diagnostics/index.html", ctx)
@ -2542,6 +2577,73 @@ async def diagnostics_perf(request: Request) -> HTMLResponse:
raise HTTPException(status_code=404, detail="Not Found") raise HTTPException(status_code=404, detail="Not Found")
return templates.TemplateResponse("diagnostics/perf.html", {"request": request}) return templates.TemplateResponse("diagnostics/perf.html", {"request": request})
@app.get("/diagnostics/quality", response_class=HTMLResponse)
async def diagnostics_quality(request: Request) -> HTMLResponse:
"""Theme catalog quality dashboard (diagnostics only)."""
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
from .services.theme_catalog_loader import load_index as _load_idx
idx = _load_idx()
# Calculate quality statistics
themes = idx.catalog.themes
total_themes = len(themes)
# Quality tier distribution
tier_counts = {'Excellent': 0, 'Good': 0, 'Fair': 0, 'Poor': 0}
quality_scores = []
for slug, tier in idx.quality_tier_by_slug.items():
if tier:
tier_counts[tier] = tier_counts.get(tier, 0) + 1
score = idx.quality_score_by_slug.get(slug)
if score is not None:
quality_scores.append(score)
avg_quality_score = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0
# Get top 10 best and bottom 10 worst themes
themes_with_scores = []
for theme in themes:
from .services.theme_catalog_loader import slugify as _slugify
slug = _slugify(theme.theme)
tier = idx.quality_tier_by_slug.get(slug)
score = idx.quality_score_by_slug.get(slug, 0.0)
pool_size = idx.pool_size_by_slug.get(slug, 0)
themes_with_scores.append({
'theme': theme.theme,
'slug': slug,
'tier': tier or 'Unknown',
'score': score,
'pool_size': pool_size,
'description': theme.description or '',
'synergy_count': len(theme.synergies) if theme.synergies else 0,
'has_fallback_description': not theme.description or theme.description.startswith('A theme focused on'),
'editorial_quality': theme.editorial_quality or 'auto',
})
# Sort by score
themes_with_scores.sort(key=lambda x: x['score'], reverse=True)
top_themes = themes_with_scores[:10]
bottom_themes = themes_with_scores[-10:]
bottom_themes.reverse() # Show worst first
ctx = {
'request': request,
'total_themes': total_themes,
'tier_counts': tier_counts,
'avg_quality_score': avg_quality_score,
'top_themes': top_themes,
'bottom_themes': bottom_themes,
}
return templates.TemplateResponse("diagnostics/quality_dashboard.html", ctx)
# --- Diagnostics: combos & synergies --- # --- Diagnostics: combos & synergies ---
@app.post("/diagnostics/combos") @app.post("/diagnostics/combos")
async def diagnostics_combos(request: Request) -> JSONResponse: async def diagnostics_combos(request: Request) -> JSONResponse:

View file

@ -332,7 +332,7 @@ async def theme_catalog_detail_page(theme_id: str, request: Request):
entry = idx.slug_to_entry.get(slug) entry = idx.slug_to_entry.get(slug)
if not entry: if not entry:
return HTMLResponse("<div class='error'>Not found.</div>", status_code=404) return HTMLResponse("<div class='error'>Not found.</div>", status_code=404)
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False) detail = project_detail(slug, entry, idx.slug_to_yaml, idx, uncapped=False)
# Strip diagnostics-only fields for public page # Strip diagnostics-only fields for public page
detail.pop('has_fallback_description', None) detail.pop('has_fallback_description', None)
detail.pop('editorial_quality', None) detail.pop('editorial_quality', None)
@ -428,12 +428,16 @@ async def theme_list_fragment(
async def theme_list_simple_fragment( async def theme_list_simple_fragment(
request: Request, request: Request,
q: str | None = None, q: str | None = None,
quality_tier: str | None = None,
pool_tier: str | None = None,
bucket: str | None = None,
limit: int | None = Query(100, ge=1, le=300), limit: int | None = Query(100, ge=1, le=300),
offset: int | None = Query(0, ge=0), offset: int | None = Query(0, ge=0),
): ):
"""Lightweight list: only id, theme, short_description (for speed). """Lightweight list: only id, theme, short_description (for speed).
Attempts fast path using precomputed theme_list.json; falls back to full index. Attempts fast path using precomputed theme_list.json; falls back to full index.
Supports filtering by quality_tier, pool_tier, and bucket (popularity).
""" """
import time as _t import time as _t
t0 = _t.time() t0 = _t.time()
@ -445,17 +449,46 @@ async def theme_list_simple_fragment(
total = 0 total = 0
if fast_items is not None: if fast_items is not None:
fast_used = True fast_used = True
# Load index to get quality and pool data
try:
idx = load_index()
except FileNotFoundError:
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
# Filter (substring on theme only) if q provided # Filter (substring on theme only) if q provided
if q: if q:
ql = q.lower() ql = q.lower()
fast_items = [e for e in fast_items if isinstance(e.get("theme"), str) and ql in e["theme"].lower()] fast_items = [e for e in fast_items if isinstance(e.get("theme"), str) and ql in e["theme"].lower()]
# Apply quality and pool tier filters
if quality_tier or pool_tier or bucket:
filtered_fast_items = []
for e in fast_items:
theme_id = e.get("id")
summary = idx.summary_by_slug.get(theme_id, {})
if quality_tier and summary.get("quality_tier") != quality_tier:
continue
if pool_tier and summary.get("pool_tier") != pool_tier:
continue
if bucket and summary.get("popularity_bucket") != bucket:
continue
filtered_fast_items.append(e)
fast_items = filtered_fast_items
total = len(fast_items) total = len(fast_items)
slice_items = fast_items[off: off + lim] slice_items = fast_items[off: off + lim]
for e in slice_items: for e in slice_items:
theme_id = e.get("id")
# Get quality and pool data from index
summary = idx.summary_by_slug.get(theme_id, {})
items.append({ items.append({
"id": e.get("id"), "id": e.get("id"),
"theme": e.get("theme"), "theme": e.get("theme"),
"short_description": e.get("short_description"), "short_description": e.get("short_description"),
"quality_tier": summary.get("quality_tier"),
"quality_score": summary.get("quality_score"),
"pool_size": summary.get("pool_size"),
"pool_tier": summary.get("pool_tier"),
"popularity_bucket": summary.get("popularity_bucket"),
}) })
else: else:
# Fallback: load full index # Fallback: load full index
@ -463,7 +496,20 @@ async def theme_list_simple_fragment(
idx = load_index() idx = load_index()
except FileNotFoundError: except FileNotFoundError:
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503) return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=None, colors=None) slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=bucket, colors=None)
# Apply quality_tier and pool_tier filters
if quality_tier or pool_tier:
filtered_slugs = []
for slug in slugs:
summary = idx.summary_by_slug.get(slug, {})
if quality_tier and summary.get("quality_tier") != quality_tier:
continue
if pool_tier and summary.get("pool_tier") != pool_tier:
continue
filtered_slugs.append(slug)
slugs = filtered_slugs
total = len(slugs) total = len(slugs)
slice_slugs = slugs[off: off + lim] slice_slugs = slugs[off: off + lim]
items_raw = summaries_for_slugs(idx, slice_slugs) items_raw = summaries_for_slugs(idx, slice_slugs)
@ -472,6 +518,11 @@ async def theme_list_simple_fragment(
"id": it.get("id"), "id": it.get("id"),
"theme": it.get("theme"), "theme": it.get("theme"),
"short_description": it.get("short_description"), "short_description": it.get("short_description"),
"quality_tier": it.get("quality_tier"),
"quality_score": it.get("quality_score"),
"pool_size": it.get("pool_size"),
"pool_tier": it.get("pool_tier"),
"popularity_bucket": it.get("popularity_bucket"),
}) })
duration_ms = int(((_t.time() - t0) * 1000)) duration_ms = int(((_t.time() - t0) * 1000))
resp = _templates.TemplateResponse( resp = _templates.TemplateResponse(
@ -511,7 +562,7 @@ async def theme_detail_fragment(
return HTMLResponse("<div class='error'>Not found.</div>", status_code=404) return HTMLResponse("<div class='error'>Not found.</div>", status_code=404)
diag = _diag_enabled() and bool(diagnostics) diag = _diag_enabled() and bool(diagnostics)
uncapped_enabled = bool(uncapped) and diag uncapped_enabled = bool(uncapped) and diag
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=uncapped_enabled) detail = project_detail(slug, entry, idx.slug_to_yaml, idx, uncapped=uncapped_enabled)
if not diag: if not diag:
detail.pop('has_fallback_description', None) detail.pop('has_fallback_description', None)
detail.pop('editorial_quality', None) detail.pop('editorial_quality', None)
@ -700,7 +751,7 @@ async def api_theme_detail(
if not entry: if not entry:
raise HTTPException(status_code=404, detail="theme_not_found") raise HTTPException(status_code=404, detail="theme_not_found")
diag = _diag_enabled() and bool(diagnostics) diag = _diag_enabled() and bool(diagnostics)
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=bool(uncapped) and diag) detail = project_detail(slug, entry, idx.slug_to_yaml, idx, uncapped=bool(uncapped) and diag)
if not diag: if not diag:
# Remove diagnostics-only fields # Remove diagnostics-only fields
detail.pop("has_fallback_description", None) detail.pop("has_fallback_description", None)

View file

@ -8,6 +8,7 @@ Responsibilities:
- Provide summary & detail projections (with synergy segmentation). - Provide summary & detail projections (with synergy segmentation).
- NEW (Phase F perf): precompute summary dicts & lowercase haystacks, and - NEW (Phase F perf): precompute summary dicts & lowercase haystacks, and
add fast filtering / result caching to accelerate list & API endpoints. add fast filtering / result caching to accelerate list & API endpoints.
- R20: Calculate theme quality scores and pool sizes for UI display.
""" """
from __future__ import annotations from __future__ import annotations
@ -69,6 +70,10 @@ class SlugThemeIndex(BaseModel):
haystack_by_slug: Dict[str, str] haystack_by_slug: Dict[str, str]
primary_color_by_slug: Dict[str, Optional[str]] primary_color_by_slug: Dict[str, Optional[str]]
secondary_color_by_slug: Dict[str, Optional[str]] secondary_color_by_slug: Dict[str, Optional[str]]
quality_tier_by_slug: Dict[str, Optional[str]] # R20: Excellent/Good/Fair/Poor
quality_score_by_slug: Dict[str, Optional[float]] # R20: 0.0-1.0 normalized quality
pool_size_by_slug: Dict[str, int] # R20: Number of cards with this theme tag
pool_tier_by_slug: Dict[str, str] # R20: Vast/Large/Moderate/Small/Tiny
mtime: float mtime: float
yaml_mtime_max: float yaml_mtime_max: float
etag: str etag: str
@ -162,6 +167,80 @@ def _compute_etag(size: int, mtime: float, yaml_mtime: float) -> str:
return f"{int(size)}-{int(mtime)}-{int(yaml_mtime)}" return f"{int(size)}-{int(mtime)}-{int(yaml_mtime)}"
def _calculate_theme_pool_sizes() -> Dict[str, int]:
"""Calculate card pool sizes for each theme from parquet data (R20).
Returns:
Dict mapping theme name to count of cards with that theme tag.
"""
pool_sizes: Dict[str, int] = {}
try:
# Import pandas and path_util inside function to avoid circular imports
import pandas as pd
import numpy as np
from path_util import get_processed_cards_path
parquet_path = get_processed_cards_path()
if not Path(parquet_path).exists():
return pool_sizes
# Read parquet file
df = pd.read_parquet(parquet_path)
if 'themeTags' not in df.columns:
return pool_sizes
# Count cards for each theme
for tags in df['themeTags']:
# Check for null/NA (works for scalars only, arrays need different check)
if tags is None or (isinstance(tags, str) and tags == ''):
continue
# Handle different formats: numpy array, list, string
if isinstance(tags, np.ndarray):
theme_list = [str(t).strip() for t in tags if t]
elif isinstance(tags, list):
theme_list = [str(t).strip() for t in tags if t]
elif isinstance(tags, str):
theme_list = [t.strip() for t in tags.split(',') if t.strip()]
else:
continue
for theme in theme_list:
if theme:
# Normalize theme name: Strip leading/trailing spaces
normalized = theme.strip()
pool_sizes[normalized] = pool_sizes.get(normalized, 0) + 1
except Exception:
# Silently fail if parquet unavailable (e.g., during initial setup)
pass
return pool_sizes
def _categorize_pool_size(count: int) -> str:
"""Categorize pool size into tier (R20).
Args:
count: Number of cards with this theme tag
Returns:
Tier: Vast/Large/Moderate/Small/Tiny
"""
if count >= 500:
return 'Vast'
elif count >= 200:
return 'Large'
elif count >= 50:
return 'Moderate'
elif count >= 15:
return 'Small'
else:
return 'Tiny'
def load_index() -> SlugThemeIndex: def load_index() -> SlugThemeIndex:
if not _needs_reload(): if not _needs_reload():
return _CACHE["index"] return _CACHE["index"]
@ -174,14 +253,92 @@ def load_index() -> SlugThemeIndex:
haystack_by_slug: Dict[str, str] = {} haystack_by_slug: Dict[str, str] = {}
primary_color_by_slug: Dict[str, Optional[str]] = {} primary_color_by_slug: Dict[str, Optional[str]] = {}
secondary_color_by_slug: Dict[str, Optional[str]] = {} secondary_color_by_slug: Dict[str, Optional[str]] = {}
quality_tier_by_slug: Dict[str, Optional[str]] = {} # R20
quality_score_by_slug: Dict[str, Optional[float]] = {} # R20
pool_size_by_slug: Dict[str, int] = {} # R20
pool_tier_by_slug: Dict[str, str] = {} # R20
# R20: Calculate pool sizes from parquet data
theme_pool_sizes = _calculate_theme_pool_sizes()
# R20: Calculate global card frequency directly to avoid circular dependency
global_card_freq: Dict[str, int] = {}
for t in catalog.themes:
if t.example_cards:
for card in t.example_cards:
global_card_freq[card] = global_card_freq.get(card, 0) + 1
total_themes = len(catalog.themes)
for t in catalog.themes: for t in catalog.themes:
slug = slugify(t.theme) slug = slugify(t.theme)
slug_to_entry[slug] = t slug_to_entry[slug] = t
summary = project_summary(t) # Will populate quality after calculation below
summary_by_slug[slug] = summary summary_by_slug[slug] = {} # Populated after quality calculation
haystack_by_slug[slug] = "|".join([t.theme] + t.synergies).lower() haystack_by_slug[slug] = "|".join([t.theme] + t.synergies).lower()
primary_color_by_slug[slug] = t.primary_color primary_color_by_slug[slug] = t.primary_color
secondary_color_by_slug[slug] = t.secondary_color secondary_color_by_slug[slug] = t.secondary_color
# R20: Calculate quality metrics inline (avoids ThemeEditorialService circular dependency)
# Enhanced scoring: Card count (0-30) + Uniqueness (0-40) + Description (0-20) + Curation (0-10) = 100 max
total_points = 0.0
max_points = 100.0
# 1. Example card count (0-30 points, 8+ cards = max)
card_count = len(t.example_cards) if t.example_cards else 0
card_points = min(30.0, (card_count / 8) * 30.0)
total_points += card_points
# 2. Uniqueness ratio (0-40 points, cards in <25% of themes)
if t.example_cards and total_themes > 0:
unique_count = sum(
1 for card in t.example_cards
if (global_card_freq.get(card, 0) / total_themes) < 0.25
)
uniqueness_ratio = unique_count / len(t.example_cards)
uniqueness_points = uniqueness_ratio * 40.0
total_points += uniqueness_points
# 3. Description quality (0-20 points: manual=10, rule=5, generic=0)
if t.description_source:
desc_bonus = {'manual': 10, 'rule': 5, 'generic': 0}.get(t.description_source, 0)
total_points += desc_bonus
# 4. Manual curation bonus (0-10 points if has curated_synergies)
if hasattr(t, 'curated_synergies') and t.curated_synergies:
total_points += 10.0
# Normalize to 0.0-1.0 and determine tier
normalized_score = total_points / max_points
if normalized_score >= 0.75:
tier = 'Excellent'
elif normalized_score >= 0.60:
tier = 'Good'
elif normalized_score >= 0.40:
tier = 'Fair'
else:
tier = 'Poor'
quality_tier_by_slug[slug] = tier
quality_score_by_slug[slug] = normalized_score
# R20: Calculate pool size from parquet data (normalize theme name for lookup)
normalized_theme_name = t.theme.strip()
pool_size = theme_pool_sizes.get(normalized_theme_name, 0)
pool_tier = _categorize_pool_size(pool_size)
pool_size_by_slug[slug] = pool_size
pool_tier_by_slug[slug] = pool_tier
# R20: Now create summary with quality and pool size data
summary = project_summary(
t,
quality_tier=tier,
quality_score=normalized_score,
pool_size=pool_size,
pool_tier=pool_tier
)
summary_by_slug[slug] = summary
yaml_map, yaml_mtime_max = _load_yaml_map() yaml_map, yaml_mtime_max = _load_yaml_map()
idx = SlugThemeIndex( idx = SlugThemeIndex(
catalog=catalog, catalog=catalog,
@ -191,6 +348,10 @@ def load_index() -> SlugThemeIndex:
haystack_by_slug=haystack_by_slug, haystack_by_slug=haystack_by_slug,
primary_color_by_slug=primary_color_by_slug, primary_color_by_slug=primary_color_by_slug,
secondary_color_by_slug=secondary_color_by_slug, secondary_color_by_slug=secondary_color_by_slug,
quality_tier_by_slug=quality_tier_by_slug, # R20
quality_score_by_slug=quality_score_by_slug, # R20
pool_size_by_slug=pool_size_by_slug, # R20
pool_tier_by_slug=pool_tier_by_slug, # R20
mtime=CATALOG_JSON.stat().st_mtime, mtime=CATALOG_JSON.stat().st_mtime,
yaml_mtime_max=yaml_mtime_max, yaml_mtime_max=yaml_mtime_max,
etag=_compute_etag(CATALOG_JSON.stat().st_size, CATALOG_JSON.stat().st_mtime, yaml_mtime_max), etag=_compute_etag(CATALOG_JSON.stat().st_size, CATALOG_JSON.stat().st_mtime, yaml_mtime_max),
@ -284,7 +445,13 @@ def has_fallback_description(entry: ThemeEntry) -> bool:
return False return False
def project_summary(entry: ThemeEntry) -> Dict[str, Any]: def project_summary(
entry: ThemeEntry,
quality_tier: Optional[str] = None,
quality_score: Optional[float] = None,
pool_size: Optional[int] = None,
pool_tier: Optional[str] = None
) -> Dict[str, Any]:
# Short description (snippet) for list hover / condensed display # Short description (snippet) for list hover / condensed display
desc = entry.description or "" desc = entry.description or ""
short_desc = desc.strip() short_desc = desc.strip()
@ -298,6 +465,10 @@ def project_summary(entry: ThemeEntry) -> Dict[str, Any]:
"popularity_bucket": entry.popularity_bucket, "popularity_bucket": entry.popularity_bucket,
"deck_archetype": entry.deck_archetype, "deck_archetype": entry.deck_archetype,
"editorial_quality": entry.editorial_quality, "editorial_quality": entry.editorial_quality,
"quality_tier": quality_tier, # R20: Quality tier (Excellent/Good/Fair/Poor)
"quality_score": quality_score, # R20: Normalized quality score (0.0-1.0)
"pool_size": pool_size, # R20: Number of cards with this theme tag
"pool_tier": pool_tier, # R20: Pool size tier (Vast/Large/Moderate/Small/Tiny)
"description": entry.description, "description": entry.description,
"short_description": short_desc, "short_description": short_desc,
"synergies": entry.synergies, "synergies": entry.synergies,
@ -317,7 +488,7 @@ def _split_synergies(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str,
} }
def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, Any]], uncapped: bool = False) -> Dict[str, Any]: def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, Any]], idx: 'SlugThemeIndex', uncapped: bool = False) -> Dict[str, Any]:
seg = _split_synergies(slug, entry, yaml_map) seg = _split_synergies(slug, entry, yaml_map)
uncapped_synergies: Optional[List[str]] = None uncapped_synergies: Optional[List[str]] = None
if uncapped: if uncapped:
@ -330,7 +501,18 @@ def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, A
full.append(s) full.append(s)
seen.add(s) seen.add(s)
uncapped_synergies = full uncapped_synergies = full
d = project_summary(entry) # R20: Look up quality and pool metrics from index (not stored on ThemeEntry)
quality_tier = idx.quality_tier_by_slug.get(slug)
quality_score = idx.quality_score_by_slug.get(slug)
pool_size = idx.pool_size_by_slug.get(slug, 0)
pool_tier = idx.pool_tier_by_slug.get(slug, "Tiny")
d = project_summary(
entry,
quality_tier=quality_tier,
quality_score=quality_score,
pool_size=pool_size,
pool_tier=pool_tier
)
d.update({ d.update({
"curated_synergies": seg["curated"], "curated_synergies": seg["curated"],
"enforced_synergies": seg["enforced"], "enforced_synergies": seg["enforced"],
@ -449,6 +631,7 @@ def summaries_for_slugs(idx: SlugThemeIndex, slugs: Iterable[str]) -> List[Dict[
for s in slugs: for s in slugs:
summ = idx.summary_by_slug.get(s) summ = idx.summary_by_slug.get(s)
if summ: if summ:
# R20: Summary already contains quality_tier and quality_score from load_index
out.append(summ.copy()) # shallow copy so route can pop diag-only fields out.append(summ.copy()) # shallow copy so route can pop diag-only fields
return out return out

View file

@ -627,6 +627,10 @@ video {
margin-bottom: 0.875rem; margin-bottom: 0.875rem;
} }
.mb-0\.5 {
margin-bottom: 0.125rem;
}
.mb-1 { .mb-1 {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
@ -659,6 +663,10 @@ video {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.ml-4 {
margin-left: 1rem;
}
.ml-6 { .ml-6 {
margin-left: 1.5rem; margin-left: 1.5rem;
} }
@ -763,8 +771,8 @@ video {
width: 100%; width: 100%;
} }
.min-w-\[160px\] { .min-w-\[140px\] {
min-width: 160px; min-width: 140px;
} }
.min-w-\[2\.5rem\] { .min-w-\[2\.5rem\] {
@ -787,6 +795,10 @@ video {
flex-shrink: 1; flex-shrink: 1;
} }
.flex-shrink-0 {
flex-shrink: 0;
}
.grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
@ -813,6 +825,10 @@ video {
resize: both; resize: both;
} }
.list-disc {
list-style-type: disc;
}
.list-none { .list-none {
list-style-type: none; list-style-type: none;
} }
@ -885,6 +901,18 @@ video {
gap: 1rem; gap: 1rem;
} }
.space-y-0\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.125rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.125rem * var(--tw-space-y-reverse));
}
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
}
.overflow-hidden { .overflow-hidden {
overflow: hidden; overflow: hidden;
} }
@ -897,6 +925,10 @@ video {
white-space: nowrap; white-space: nowrap;
} }
.rounded {
border-radius: 0.25rem;
}
.rounded-\[10px\] { .rounded-\[10px\] {
border-radius: 10px; border-radius: 10px;
} }
@ -921,6 +953,16 @@ video {
border-color: var(--border); border-color: var(--border);
} }
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
}
.bg-gray-50 {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
}
.bg-gray-700 { .bg-gray-700 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
@ -934,6 +976,10 @@ video {
padding: 0.5rem; padding: 0.5rem;
} }
.p-3 {
padding: 0.75rem;
}
.px-1\.5 { .px-1\.5 {
padding-left: 0.375rem; padding-left: 0.375rem;
padding-right: 0.375rem; padding-right: 0.375rem;
@ -944,6 +990,11 @@ video {
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-0\.5 { .py-0\.5 {
padding-top: 0.125rem; padding-top: 0.125rem;
padding-bottom: 0.125rem; padding-bottom: 0.125rem;
@ -954,6 +1005,11 @@ video {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
.text-left { .text-left {
text-align: left; text-align: left;
} }
@ -962,6 +1018,10 @@ video {
text-align: center; text-align: center;
} }
.text-\[10px\] {
font-size: 10px;
}
.text-\[11px\] { .text-\[11px\] {
font-size: 11px; font-size: 11px;
} }
@ -1018,6 +1078,16 @@ video {
color: rgb(229 231 235 / var(--tw-text-opacity, 1)); color: rgb(229 231 235 / var(--tw-text-opacity, 1));
} }
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
}
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }
@ -1030,6 +1100,10 @@ video {
opacity: 0.3; opacity: 0.3;
} }
.opacity-60 {
opacity: 0.6;
}
.opacity-70 { .opacity-70 {
opacity: 0.7; opacity: 0.7;
} }
@ -4806,6 +4880,62 @@ img.lqip.loaded {
color: #fff; color: #fff;
} }
/* Quality tier badges (editorial quality scoring) */
.badge-quality-excellent {
background: #10b981;
color: #fff;
}
.badge-quality-good {
background: #3b82f6;
color: #fff;
}
.badge-quality-fair {
background: #f59e0b;
color: #fff;
}
.badge-quality-poor {
background: #ef4444;
color: #fff;
}
/* Pool size tier badges (card availability) */
.badge-pool-vast {
background: #8b5cf6;
/* violet */
color: #fff;
}
.badge-pool-large {
background: #14b8a6;
/* teal */
color: #fff;
}
.badge-pool-moderate {
background: #06b6d4;
/* cyan */
color: #fff;
}
.badge-pool-small {
background: #f97316;
/* orange */
color: #fff;
}
.badge-pool-tiny {
background: #6b7280;
/* gray */
color: #fff;
}
/* Legacy lifecycle quality badges (draft/reviewed/final) */
.badge-quality-draft { .badge-quality-draft {
background: #4338ca; background: #4338ca;
color: #fff; color: #fff;
@ -4822,6 +4952,8 @@ img.lqip.loaded {
font-weight: 600; font-weight: 600;
} }
/* Popularity bucket badges */
.badge-pop-vc { .badge-pop-vc {
background: #065f46; background: #065f46;
color: #fff; color: #fff;
@ -5687,3 +5819,28 @@ footer.site-footer {
flex-shrink: 0; flex-shrink: 0;
} }
.hover\:opacity-100:hover {
opacity: 1;
}
@media (prefers-color-scheme: dark) {
.dark\:border-gray-700 {
--tw-border-opacity: 1;
border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
}
.dark\:bg-gray-800\/50 {
background-color: rgb(31 41 55 / 0.5);
}
.dark\:text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.dark\:text-gray-400 {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
}

View file

@ -2685,6 +2685,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
color: #fff; color: #fff;
} }
/* Quality tier badges (editorial quality scoring) */
.badge-quality-excellent {
background: #10b981;
color: #fff;
}
.badge-quality-good {
background: #3b82f6;
color: #fff;
}
.badge-quality-fair {
background: #f59e0b;
color: #fff;
}
.badge-quality-poor {
background: #ef4444;
color: #fff;
}
/* Pool size tier badges (card availability) */
.badge-pool-vast {
background: #8b5cf6; /* violet */
color: #fff;
}
.badge-pool-large {
background: #14b8a6; /* teal */
color: #fff;
}
.badge-pool-moderate {
background: #06b6d4; /* cyan */
color: #fff;
}
.badge-pool-small {
background: #f97316; /* orange */
color: #fff;
}
.badge-pool-tiny {
background: #6b7280; /* gray */
color: #fff;
}
/* Legacy lifecycle quality badges (draft/reviewed/final) */
.badge-quality-draft { .badge-quality-draft {
background: #4338ca; background: #4338ca;
color: #fff; color: #fff;
@ -2701,6 +2749,7 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
font-weight: 600; font-weight: 600;
} }
/* Popularity bucket badges */
.badge-pop-vc { .badge-pop-vc {
background: #065f46; background: #065f46;
color: #fff; color: #fff;

View file

@ -34,7 +34,7 @@
<script> <script>
window.__telemetryEndpoint = '/telemetry/events'; window.__telemetryEndpoint = '/telemetry/events';
</script> </script>
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" /> <link rel="stylesheet" href="/static/styles.css?v=20260319-3" />
<link rel="stylesheet" href="/static/shared-components.css?v=20251021-1" /> <link rel="stylesheet" href="/static/shared-components.css?v=20251021-1" />
<style> <style>
/* Disable all transitions until page is loaded to prevent sidebar flash */ /* Disable all transitions until page is loaded to prevent sidebar flash */

View file

@ -3,9 +3,9 @@
<section> <section>
<h2>Diagnostics</h2> <h2>Diagnostics</h2>
<p class="muted">Use these tools to verify error handling surfaces.</p> <p class="muted">Use these tools to verify error handling surfaces.</p>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem"> <details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">System summary</h3> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">System summary</summary>
<div id="sysSummary" class="muted">Loading…</div> <div id="sysSummary" class="muted" style="margin-top:.5rem">Loading…</div>
<div id="envFlags" style="margin-top:.5rem"></div> <div id="envFlags" style="margin-top:.5rem"></div>
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div> <div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div>
<div id="themeSummary" style="margin-top:.5rem"></div> <div id="themeSummary" style="margin-top:.5rem"></div>
@ -13,9 +13,49 @@
<div style="margin-top:.35rem"> <div style="margin-top:.35rem">
<button class="btn" id="diag-theme-reset">Reset theme preference</button> <button class="btn" id="diag-theme-reset">Reset theme preference</button>
</div> </div>
</div> </details>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Multi-face merge snapshot</h3> {# Theme Quality Overview #}
{% if quality_stats %}
<details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">Theme Catalog Quality</summary>
<div class="muted" style="margin-bottom:.5rem; margin-top:.5rem">Quick overview of theme quality metrics</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<div class="muted" style="font-size: 11px;">Total Themes</div>
<div style="font-size: 20px; font-weight: 600;">{{ quality_stats.total_themes }}</div>
</div>
<div>
<div class="muted" style="font-size: 11px;">Average Quality</div>
<div style="font-size: 20px; font-weight: 600;">{{ (quality_stats.avg_quality_score * 100)|round|int }}%</div>
</div>
<div>
<div class="muted" style="font-size: 11px; color: #10b981;">Excellent</div>
<div style="font-size: 20px; font-weight: 600; color: #10b981;">{{ quality_stats.tier_counts.Excellent }}</div>
</div>
<div>
<div class="muted" style="font-size: 11px; color: #3b82f6;">Good</div>
<div style="font-size: 20px; font-weight: 600; color: #3b82f6;">{{ quality_stats.tier_counts.Good }}</div>
</div>
<div>
<div class="muted" style="font-size: 11px; color: #f59e0b;">Fair</div>
<div style="font-size: 20px; font-weight: 600; color: #f59e0b;">{{ quality_stats.tier_counts.Fair }}</div>
</div>
<div>
<div class="muted" style="font-size: 11px; color: #ef4444;">Poor</div>
<div style="font-size: 20px; font-weight: 600; color: #ef4444;">{{ quality_stats.tier_counts.Poor }}</div>
</div>
</div>
<div style="margin-top: .75rem;">
<a href="/diagnostics/quality" class="btn" style="text-decoration: none;">View Full Quality Dashboard →</a>
</div>
</details>
{% endif %}
<details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">Multi-face merge snapshot</summary>
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div> <div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %} {% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
{% if colors %} {% if colors %}
@ -70,25 +110,25 @@
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div> <div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
{% endif %} {% endif %}
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div> <div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
</div> </details>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem"> <details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Dual-Commander diagnostics</h3> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">Dual-Commander diagnostics</summary>
<div class="muted" style="margin-bottom:.35rem;">Latest partner, partner-with, doctor, and background pairings with color sources.</div> <div class="muted" style="margin-bottom:.35rem; margin-top:.5rem;">Latest partner, partner-with, doctor, and background pairings with color sources.</div>
<div id="partnerMetricsSummary" class="muted">Loading partner metrics…</div> <div id="partnerMetricsSummary" class="muted">Loading partner metrics…</div>
<div id="partnerMetricsModes" class="muted" style="margin-top:.5rem;"></div> <div id="partnerMetricsModes" class="muted" style="margin-top:.5rem;"></div>
<div id="partnerColorSources" style="margin-top:.5rem;"></div> <div id="partnerColorSources" style="margin-top:.5rem;"></div>
</div> </details>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem"> <details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Performance (local)</h3> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">Performance (local)</summary>
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div> <div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
<div style="display:flex; gap:1rem; flex-wrap:wrap"> <div style="display:flex; gap:1rem; flex-wrap:wrap">
<div><strong>Scroll FPS:</strong> <span id="perf-fps"></span></div> <div><strong>Scroll FPS:</strong> <span id="perf-fps"></span></div>
<div><strong>Visible tiles:</strong> <span id="perf-visible"></span></div> <div><strong>Visible tiles:</strong> <span id="perf-visible"></span></div>
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div> <div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
</div> </div>
</div> </details>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem"> <details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Combos & Synergies (ad-hoc)</h3> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">Combos & Synergies (ad-hoc)</summary>
<div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div> <div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div>
<textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea> <textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea>
<div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center"> <div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center">
@ -96,21 +136,21 @@
<small class="muted">Runs in diagnostics mode only.</small> <small class="muted">Runs in diagnostics mode only.</small>
</div> </div>
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre> <pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
</div> </details>
{% if enable_pwa %} {% if enable_pwa %}
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem"> <details class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">PWA status</h3> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">PWA status</summary>
<div id="pwaStatus" class="muted">Checking…</div> <div id="pwaStatus" class="muted" style="margin-top:.5rem">Checking…</div>
</div> </details>
{% endif %} {% endif %}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;"> <details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;">
<h3 style="margin-top:0">Error triggers</h3> <summary style="cursor:pointer; user-select:none; margin-top:0; font-size:1.17em; font-weight:bold;">Error triggers</summary>
<div class="row" style="display:flex; gap:.5rem; align-items:center"> <div class="row" style="display:flex; gap:.5rem; align-items:center">
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button> <button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button> <button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
<small class="muted">You should see a toast and an inline banner with Request-ID.</small> <small class="muted">You should see a toast and an inline banner with Request-ID.</small>
</div> </div>
</div> </details>
{% if show_logs %} {% if show_logs %}
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p> <p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
{% endif %} {% endif %}

View file

@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Theme Quality Dashboard</h2>
<p class="muted">Monitor theme catalog health and quality metrics</p>
{# Summary Statistics #}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Catalog Statistics</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div>
<div class="muted" style="font-size: 12px;">Total Themes</div>
<div style="font-size: 24px; font-weight: 600;">{{ total_themes }}</div>
</div>
<div>
<div class="muted" style="font-size: 12px;">Average Quality Score</div>
<div style="font-size: 24px; font-weight: 600;">{{ (avg_quality_score * 100)|round|int }}%</div>
</div>
</div>
</div>
{# Quality Distribution #}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Quality Tier Distribution</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
<div>
<div style="font-size: 12px; color: #10b981; font-weight: 600;">Excellent (≥75%)</div>
<div style="font-size: 32px; font-weight: 700; color: #10b981;">{{ tier_counts.Excellent }}</div>
<div class="muted" style="font-size: 11px;">{{ ((tier_counts.Excellent / total_themes) * 100)|round(1) }}% of catalog</div>
</div>
<div>
<div style="font-size: 12px; color: #3b82f6; font-weight: 600;">Good (60-74%)</div>
<div style="font-size: 32px; font-weight: 700; color: #3b82f6;">{{ tier_counts.Good }}</div>
<div class="muted" style="font-size: 11px;">{{ ((tier_counts.Good / total_themes) * 100)|round(1) }}% of catalog</div>
</div>
<div>
<div style="font-size: 12px; color: #f59e0b; font-weight: 600;">Fair (40-59%)</div>
<div style="font-size: 32px; font-weight: 700; color: #f59e0b;">{{ tier_counts.Fair }}</div>
<div class="muted" style="font-size: 11px;">{{ ((tier_counts.Fair / total_themes) * 100)|round(1) }}% of catalog</div>
</div>
<div>
<div style="font-size: 12px; color: #ef4444; font-weight: 600;">Poor (&lt;40%)</div>
<div style="font-size: 32px; font-weight: 700; color: #ef4444;">{{ tier_counts.Poor }}</div>
<div class="muted" style="font-size: 11px;">{{ ((tier_counts.Poor / total_themes) * 100)|round(1) }}% of catalog</div>
</div>
</div>
</div>
{# Top 10 Highest Quality Themes #}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Top 10 Highest Quality Themes</h3>
<div class="muted" style="margin-bottom:.5rem">Well-curated themes with high scores</div>
<div style="overflow-x:auto">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="border-bottom:1px solid var(--border); text-align:left;">
<th style="padding:.35rem .5rem;">Rank</th>
<th style="padding:.35rem .5rem;">Theme</th>
<th style="padding:.35rem .5rem;">Tier</th>
<th style="padding:.35rem .5rem;">Score</th>
<th style="padding:.35rem .5rem;">Pool Size</th>
<th style="padding:.35rem .5rem;">Synergies</th>
<th style="padding:.35rem .5rem;">Editorial</th>
</tr>
</thead>
<tbody>
{% for theme in top_themes %}
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
<td style="padding:.35rem .5rem; font-weight:600;">{{ loop.index }}</td>
<td style="padding:.35rem .5rem;">
<a href="/themes/{{ theme.slug }}" style="text-decoration: none; color: var(--link-color);">{{ theme.theme }}</a>
</td>
<td style="padding:.35rem .5rem;">
<span class="theme-badge badge-quality-{{ theme.tier|lower }}">{{ theme.tier }}</span>
</td>
<td style="padding:.35rem .5rem; font-weight:600;">{{ (theme.score * 100)|round|int }}%</td>
<td style="padding:.35rem .5rem;">{{ theme.pool_size }}</td>
<td style="padding:.35rem .5rem;">{{ theme.synergy_count }}</td>
<td style="padding:.35rem .5rem; text-transform: capitalize;">{{ theme.editorial_quality }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# Bottom 10 Lowest Quality Themes #}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Bottom 10 Lowest Quality Themes</h3>
<div class="muted" style="margin-bottom:.5rem">Themes that need improvement</div>
<div style="overflow-x:auto">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="border-bottom:1px solid var(--border); text-align:left;">
<th style="padding:.35rem .5rem;">Theme</th>
<th style="padding:.35rem .5rem;">Tier</th>
<th style="padding:.35rem .5rem;">Score</th>
<th style="padding:.35rem .5rem;">Pool Size</th>
<th style="padding:.35rem .5rem;">Synergies</th>
<th style="padding:.35rem .5rem;">Issues</th>
<th style="padding:.35rem .5rem;">Suggestions</th>
</tr>
</thead>
<tbody>
{% for theme in bottom_themes %}
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
<td style="padding:.35rem .5rem;">
<a href="/themes/{{ theme.slug }}" style="text-decoration: none; color: var(--link-color);">{{ theme.theme }}</a>
</td>
<td style="padding:.35rem .5rem;">
<span class="theme-badge badge-quality-{{ theme.tier|lower }}">{{ theme.tier }}</span>
</td>
<td style="padding:.35rem .5rem; font-weight:600;">{{ (theme.score * 100)|round|int }}%</td>
<td style="padding:.35rem .5rem;">{{ theme.pool_size }}</td>
<td style="padding:.35rem .5rem;">{{ theme.synergy_count }}</td>
<td style="padding:.35rem .5rem; font-size: 11px;">
{% set issues = [] %}
{% if theme.pool_size < 15 %}{% set _ = issues.append('Low card count') %}{% endif %}
{% if theme.synergy_count < 3 %}{% set _ = issues.append('Few synergies') %}{% endif %}
{% if theme.has_fallback_description %}{% set _ = issues.append('Auto-generated desc') %}{% endif %}
{% if theme.editorial_quality == 'auto' %}{% set _ = issues.append('Not reviewed') %}{% endif %}
{{ issues|join(', ') or 'None identified' }}
</td>
<td style="padding:.35rem .5rem; font-size: 11px;">
{% set suggestions = [] %}
{% if theme.pool_size < 15 %}{% set _ = suggestions.append('Add example cards') %}{% endif %}
{% if theme.synergy_count < 3 %}{% set _ = suggestions.append('Define synergies') %}{% endif %}
{% if theme.has_fallback_description %}{% set _ = suggestions.append('Write custom description') %}{% endif %}
{% if theme.editorial_quality == 'auto' %}{% set _ = suggestions.append('Review & curate') %}{% endif %}
{{ suggestions|join('; ') or 'N/A' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# Tools and Links #}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Quality Improvement Tools</h3>
<div style="margin-bottom: 1rem;">
<h4 style="margin: 0 0 .5rem 0; font-size: 14px;">Run Linter</h4>
<p class="muted" style="margin: 0 0 .5rem 0; font-size: 12px;">Analyze theme catalog for quality issues and get actionable suggestions</p>
<code style="display: block; background: rgba(0,0,0,0.1); padding: .5rem; border-radius: 6px; font-size: 12px; overflow-x: auto;">
python code/scripts/validate_theme_catalog.py --lint
</code>
</div>
<div>
<h4 style="margin: 1rem 0 .5rem 0; font-size: 14px;">Documentation</h4>
<ul style="margin: 0; padding-left: 1.25rem; font-size: 13px;">
<li><a href="https://github.com/mwisnowski/mtg_python_deckbuilder/blob/main/docs/theme_editorial_guide.md" target="_blank" rel="noopener noreferrer" style="color: var(--link-color);">Theme Editorial Guide</a> - Quality scoring methodology and best practices</li>
<li><a href="/themes" style="color: var(--link-color);">Browse Themes</a> - View all themes with quality badges</li>
</ul>
</div>
</div>
</section>
{% endblock %}

View file

@ -8,7 +8,29 @@
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" class="w-full" autocomplete="off" /> <input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" class="w-full" autocomplete="off" />
<div id="theme-search-results" class="search-suggestions"></div> <div id="theme-search-results" class="search-suggestions"></div>
</div> </div>
<div class="min-w-[160px]"> {% if show_theme_filters %}
<div class="min-w-[140px]">
<label class="text-[11px] block opacity-70">Quality</label>
<select id="quality-filter" class="w-full text-[13px]">
<option value="">All</option>
<option>Excellent</option>
<option>Good</option>
<option>Fair</option>
<option>Poor</option>
</select>
</div>
<div class="min-w-[140px]">
<label class="text-[11px] block opacity-70">Pool Size</label>
<select id="pool-filter" class="w-full text-[13px]">
<option value="">All</option>
<option>Vast</option>
<option>Large</option>
<option>Moderate</option>
<option>Small</option>
<option>Tiny</option>
</select>
</div>
<div class="min-w-[140px]">
<label class="text-[11px] block opacity-70">Popularity</label> <label class="text-[11px] block opacity-70">Popularity</label>
<select id="pop-filter" class="w-full text-[13px]"> <select id="pop-filter" class="w-full text-[13px]">
<option value="">All</option> <option value="">All</option>
@ -19,13 +41,78 @@
<option>Rare</option> <option>Rare</option>
</select> </select>
</div> </div>
{% endif %}
<button id="clear-search" class="btn btn-ghost text-xs" hidden>Clear</button> <button id="clear-search" class="btn btn-ghost text-xs" hidden>Clear</button>
</div> </div>
<div id="quick-popularity" class="flex gap-1.5 flex-wrap mb-2">
{% for b in ['Very Common','Common','Uncommon','Niche','Rare'] %} {% if show_theme_filters %}
<button class="btn btn-ghost pop-chip text-[11px] px-2 py-0.5" data-pop="{{ b }}">{{ b }}</button> <div id="quick-filters" class="flex gap-2.5 flex-wrap mb-2">
{% endfor %} <div>
<div class="text-[10px] opacity-60 mb-0.5">Quality</div>
<div class="flex gap-1 flex-wrap">
{% for b in ['Excellent','Good','Fair','Poor'] %}
<button class="btn btn-ghost quality-chip text-[11px] px-2 py-0.5" data-quality="{{ b }}">{{ b[0] }}</button>
{% endfor %}
</div>
</div>
<div>
<div class="text-[10px] opacity-60 mb-0.5">Pool Size</div>
<div class="flex gap-1 flex-wrap">
{% for b in ['Vast','Large','Moderate','Small','Tiny'] %}
<button class="btn btn-ghost pool-chip text-[11px] px-2 py-0.5" data-pool="{{ b }}">{{ b[0] }}</button>
{% endfor %}
</div>
</div>
<div>
<div class="text-[10px] opacity-60 mb-0.5">Popularity</div>
<div class="flex gap-1 flex-wrap">
{% for b in ['Very Common','Common','Uncommon','Niche','Rare'] %}
<button class="btn btn-ghost pop-chip text-[11px] px-2 py-0.5" data-pop="{{ b }}">{{ b }}</button>
{% endfor %}
</div>
</div>
</div> </div>
{% endif %}
{# Badge Legend #}
<details class="mb-3 text-xs" style="max-width: 800px;">
<summary class="cursor-pointer opacity-70 hover:opacity-100" style="user-select: none;">
Badge Legend
</summary>
<div class="mt-2 p-3 rounded" style="background: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex flex-col gap-2.5">
{% if show_theme_quality_badges %}
<div>
<div class="font-semibold mb-1">Quality Badges <span class="theme-badge badge-quality-excellent">E</span> <span class="theme-badge badge-quality-good">G</span> <span class="theme-badge badge-quality-fair">F</span> <span class="theme-badge badge-quality-poor">P</span></div>
<div class="opacity-85">Editorial quality measures how well we've documented and curated each theme. The score is calculated from multiple factors:</div>
<ul class="mt-1 mb-1 opacity-85 text-[11px] list-disc ml-4 space-y-0.5">
<li><strong>Card Count & Uniqueness</strong>: Themes with sufficient representative cards and unique synergies score higher</li>
<li><strong>Description Quality</strong>: Custom descriptions that avoid generic patterns score higher than auto-generated fallbacks</li>
<li><strong>Metadata Completeness</strong>: Presence of primary/secondary colors, archetype classification, and popularity data</li>
<li><strong>Curation Status</strong>: Themes marked as reviewed or final have higher quality than draft entries</li>
</ul>
<div class="opacity-70 text-[11px]">Higher quality = better-documented, more thoroughly curated, easier to build around</div>
<div class="mt-1 opacity-70 text-[11px]"><strong>Tiers:</strong> Excellent (75%+) • Good (60-74%) • Fair (40-59%) • Poor (&lt;40%)</div>
<div class="mt-1.5 opacity-70 text-[11px]">💡 <a href="https://github.com/mwisnowski/mtg_python_deckbuilder" target="_blank" rel="noopener noreferrer" class="underline hover:opacity-100">Help improve theme quality on GitHub</a></div>
</div>
{% endif %}
{% if show_theme_pool_badges %}
<div>
<div class="font-semibold mb-1">Pool Size Badges <span class="theme-badge badge-pool-vast">V</span> <span class="theme-badge badge-pool-large">L</span> <span class="theme-badge badge-pool-moderate">M</span> <span class="theme-badge badge-pool-small">S</span> <span class="theme-badge badge-pool-tiny">T</span></div>
<div class="opacity-85">Total cards available with this theme tag. Larger pools offer more variety and flexibility when building.</div>
<div class="mt-1 opacity-70 text-[11px]">• Vast (500+) • Large (200-499) • Moderate (50-199) • Small (15-49) • Tiny (&lt;15)</div>
</div>
{% endif %}
{% if show_theme_popularity_badges %}
<div>
<div class="font-semibold mb-1">Popularity <span class="theme-badge badge-pop-vc">Very Common</span> <span class="theme-badge badge-pop-c">Common</span> <span class="theme-badge badge-pop-u">Uncommon</span> <span class="theme-badge badge-pop-n">Niche</span> <span class="theme-badge badge-pop-r">Rare</span></div>
<div class="opacity-85">Usage frequency based on how many commanders and cards are associated with this theme. More popular themes appear in more decks.</div>
</div>
{% endif %}
</div>
</div>
</details>
<div id="active-filters" class="flex gap-1.5 flex-wrap mb-2 text-[11px]"></div> <div id="active-filters" class="flex gap-1.5 flex-wrap mb-2 text-[11px]"></div>
<div id="theme-results" aria-live="polite" aria-busy="true"> <div id="theme-results" aria-live="polite" aria-busy="true">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -38,7 +125,11 @@
const input = document.getElementById('theme-search'); const input = document.getElementById('theme-search');
const resultsBox = document.getElementById('theme-search-results'); const resultsBox = document.getElementById('theme-search-results');
const clearBtn = document.getElementById('clear-search'); const clearBtn = document.getElementById('clear-search');
const qualityFilter = document.getElementById('quality-filter');
const poolFilter = document.getElementById('pool-filter');
const popSel = document.getElementById('pop-filter'); const popSel = document.getElementById('pop-filter');
const qualityChips = document.querySelectorAll('.quality-chip');
const poolChips = document.querySelectorAll('.pool-chip');
const popChips = document.querySelectorAll('.pop-chip'); const popChips = document.querySelectorAll('.pop-chip');
const activeFilters = document.getElementById('active-filters'); const activeFilters = document.getElementById('active-filters');
const resultsHost = document.getElementById('theme-results'); const resultsHost = document.getElementById('theme-results');
@ -48,7 +139,9 @@
function buildParams(){ function buildParams(){
const params = new URLSearchParams(); const params = new URLSearchParams();
const q = input.value.trim(); if(q) params.set('q', q); const q = input.value.trim(); if(q) params.set('q', q);
const pop = popSel.value; if(pop) params.set('bucket', pop); const quality = qualityFilter ? qualityFilter.value : ''; if(quality) params.set('quality_tier', quality);
const pool = poolFilter ? poolFilter.value : ''; if(pool) params.set('pool_tier', pool);
const pop = popSel ? popSel.value : ''; if(pop) params.set('bucket', pop);
params.set('limit','50'); params.set('offset','0'); params.set('limit','50'); params.set('offset','0');
return params.toString(); return params.toString();
} }
@ -62,7 +155,9 @@
function renderActive(){ function renderActive(){
activeFilters.innerHTML=''; activeFilters.innerHTML='';
const q = input.value.trim(); if(q) addChip('Search: '+q, ()=>{ input.value=''; fetchList(); }); const q = input.value.trim(); if(q) addChip('Search: '+q, ()=>{ input.value=''; fetchList(); });
const pop = popSel.value; if(pop) addChip('Popularity: '+pop, ()=>{ popSel.value=''; fetchList(); }); const quality = qualityFilter ? qualityFilter.value : ''; if(quality) addChip('Quality: '+quality, ()=>{ qualityFilter.value=''; fetchList(); });
const pool = poolFilter ? poolFilter.value : ''; if(pool) addChip('Pool: '+pool, ()=>{ poolFilter.value=''; fetchList(); });
const pop = popSel ? popSel.value : ''; if(pop) addChip('Popularity: '+pop, ()=>{ popSel.value=''; fetchList(); });
} }
function fetchList(){ function fetchList(){
const ps = buildParams(); const ps = buildParams();
@ -154,7 +249,18 @@
fetch('/themes/fragment/detail/'+id,{cache:'reload'}).catch(()=>{}); fetch('/themes/fragment/detail/'+id,{cache:'reload'}).catch(()=>{});
}); });
document.addEventListener('click', function(ev){ if(!resultsBox.contains(ev.target) && ev.target!==input){ hideResults(); } }); document.addEventListener('click', function(ev){ if(!resultsBox.contains(ev.target) && ev.target!==input){ hideResults(); } });
popSel.addEventListener('change', fetchList); popChips.forEach(ch=> ch.addEventListener('click', ()=>{ popSel.value=ch.getAttribute('data-pop'); fetchList(); })); if (qualityFilter) {
qualityFilter.addEventListener('change', fetchList);
qualityChips.forEach(ch=> ch.addEventListener('click', ()=>{ qualityFilter.value=ch.getAttribute('data-quality'); fetchList(); }));
}
if (poolFilter) {
poolFilter.addEventListener('change', fetchList);
poolChips.forEach(ch=> ch.addEventListener('click', ()=>{ poolFilter.value=ch.getAttribute('data-pool'); fetchList(); }));
}
if (popSel) {
popSel.addEventListener('change', fetchList);
popChips.forEach(ch=> ch.addEventListener('click', ()=>{ popSel.value=ch.getAttribute('data-pop'); fetchList(); }));
}
// Initial load // Initial load
fetchList(); fetchList();
})(); })();

View file

@ -18,10 +18,148 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="text-xs mb-2 flex gap-2 flex-wrap"> <div class="text-xs mb-2 flex gap-2 flex-wrap">
{% if theme.popularity_bucket %}<span class="theme-badge {% if theme.popularity_bucket=='Very Common' %}badge-pop-vc{% elif theme.popularity_bucket=='Common' %}badge-pop-c{% elif theme.popularity_bucket=='Uncommon' %}badge-pop-u{% elif theme.popularity_bucket=='Niche' %}badge-pop-n{% elif theme.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ theme.popularity_bucket }}" aria-label="Popularity bucket: {{ theme.popularity_bucket }}">{{ theme.popularity_bucket }}</span>{% endif %} {% if show_theme_popularity_badges and theme.popularity_bucket %}<span class="theme-badge {% if theme.popularity_bucket=='Very Common' %}badge-pop-vc{% elif theme.popularity_bucket=='Common' %}badge-pop-c{% elif theme.popularity_bucket=='Uncommon' %}badge-pop-u{% elif theme.popularity_bucket=='Niche' %}badge-pop-n{% elif theme.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ theme.popularity_bucket }}" aria-label="Popularity bucket: {{ theme.popularity_bucket }}">{{ theme.popularity_bucket }}</span>{% endif %}
{% if show_theme_quality_badges and theme.quality_tier %}<span class="theme-badge badge-quality-{{ theme.quality_tier|lower }}" title="Quality: {{ theme.quality_tier }} ({{ (theme.quality_score * 100)|round|int }}%)" aria-label="Quality tier: {{ theme.quality_tier }}">{{ theme.quality_tier }}</span>{% endif %}
{% if show_theme_pool_badges and theme.pool_tier %}<span class="theme-badge badge-pool-{{ theme.pool_tier|lower }}" title="Pool: {{ theme.pool_tier }} (~{{ theme.pool_size }} cards)" aria-label="Pool tier: {{ theme.pool_tier }}">{{ theme.pool_tier }}</span>{% endif %}
{% if diagnostics and theme.editorial_quality %}<span class="theme-badge badge-quality-{{ theme.editorial_quality }}" title="Editorial quality: {{ theme.editorial_quality }}" aria-label="Editorial quality: {{ theme.editorial_quality }}">{{ theme.editorial_quality }}</span>{% endif %} {% if diagnostics and theme.editorial_quality %}<span class="theme-badge badge-quality-{{ theme.editorial_quality }}" title="Editorial quality: {{ theme.editorial_quality }}" aria-label="Editorial quality: {{ theme.editorial_quality }}">{{ theme.editorial_quality }}</span>{% endif %}
{% if diagnostics and theme.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback generic description" aria-label="Fallback generic description">Fallback</span>{% endif %} {% if diagnostics and theme.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback generic description" aria-label="Fallback generic description">Fallback</span>{% endif %}
</div> </div>
<!-- Badge Explanations -->
{% if (show_theme_quality_badges and theme.quality_tier) or (show_theme_pool_badges and theme.pool_tier) or (show_theme_popularity_badges and theme.popularity_bucket) %}
<details class="mt-3 text-xs" style="max-width: 800px;" open>
<summary class="cursor-pointer opacity-70 hover:opacity-100 font-semibold" style="user-select: none;">
Badge Details
</summary>
<div class="mt-2 space-y-3 px-4 py-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
{% if show_theme_quality_badges and theme.quality_tier %}
<div class="badge-explanation">
<div class="flex items-start gap-2">
<span class="theme-badge badge-quality-{{ theme.quality_tier|lower }} flex-shrink-0"
title="Quality: {{ theme.quality_tier }} ({{ (theme.quality_score * 100)|round|int }}%)">
{{ theme.quality_tier }}
</span>
<div class="flex-1 text-sm text-gray-700 dark:text-gray-300">
<strong>Quality Score:</strong> {{ (theme.quality_score * 100)|round|int }}%
<ul class="mt-1 ml-4 list-disc space-y-0.5 text-xs text-gray-600 dark:text-gray-400">
{% if theme.quality_score >= 0.70 %}
<li>High card count and unique synergies ({{ theme.synergy_count }} synergies)</li>
{% elif theme.quality_score >= 0.60 %}
<li>Good card selection with {{ theme.synergy_count }} documented synergies</li>
{% elif theme.quality_score >= 0.40 %}
<li>Moderate card pool with {{ theme.synergy_count }} synergies</li>
{% else %}
<li>Limited card pool and synergies ({{ theme.synergy_count }} synergies)</li>
{% endif %}
{% if theme.has_fallback_description %}
<li>Auto-generated description (could benefit from custom curation)</li>
{% else %}
<li>Custom curated description</li>
{% endif %}
{% if theme.curated_synergies or theme.enforced_synergies or theme.inferred_synergies %}
<li>Synergy breakdown: {{ theme.curated_synergies|length }} curated, {{ theme.enforced_synergies|length }} enforced, {{ theme.inferred_synergies|length }} inferred</li>
{% else %}
<li>No synergy breakdown available</li>
{% endif %}
{% if theme.deck_archetype %}
<li>Deck archetype: {{ theme.deck_archetype }}</li>
{% else %}
<li>No deck archetype classification</li>
{% endif %}
{% if theme.editorial_quality == 'final' %}
<li>Fully reviewed and curated (editorial status: final)</li>
{% elif theme.editorial_quality == 'refined' %}
<li>Reviewed and refined (editorial status: refined)</li>
{% elif theme.editorial_quality == 'draft' %}
<li>Draft quality (editorial status: draft)</li>
{% elif theme.editorial_quality == 'auto' %}
<li>Auto-generated content (editorial status: auto)</li>
{% endif %}
</ul>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-500">💡 <a href="https://github.com/mwisnowski/mtg_python_deckbuilder" target="_blank" rel="noopener noreferrer" class="underline hover:text-gray-700 dark:hover:text-gray-300">Help improve theme quality on GitHub</a></div>
</div>
</div>
</div>
{% endif %}
{% if show_theme_pool_badges and theme.pool_tier %}
<div class="badge-explanation">
<div class="flex items-start gap-2">
<span class="theme-badge badge-pool-{{ theme.pool_tier|lower }} flex-shrink-0"
title="Pool: {{ theme.pool_tier }} (~{{ theme.pool_size }} cards)">
{{ theme.pool_tier }}
</span>
<div class="flex-1 text-sm text-gray-700 dark:text-gray-300">
<strong>Card Pool Size:</strong> ~{{ theme.pool_size }} cards available
<ul class="mt-1 ml-4 list-disc space-y-0.5 text-xs text-gray-600 dark:text-gray-400">
{% if theme.pool_tier == 'Vast' %}
<li>Extensive card selection with {{ theme.pool_size }}+ cards available for deckbuilding</li>
<li>Provides maximum flexibility and optimization potential</li>
{% elif theme.pool_tier == 'Large' %}
<li>Large card selection with {{ theme.pool_size }} cards available</li>
<li>Offers strong flexibility for different deck strategies</li>
{% elif theme.pool_tier == 'Moderate' %}
<li>Moderate selection with {{ theme.pool_size }} cards available</li>
<li>Sufficient options for focused deck strategies</li>
{% elif theme.pool_tier == 'Small' %}
<li>Limited selection with {{ theme.pool_size }} cards available</li>
<li>May require creative deckbuilding approaches</li>
{% elif theme.pool_tier == 'Tiny' %}
<li>Very limited pool with only {{ theme.pool_size }} cards available</li>
<li>Highly focused niche theme with restricted card choices</li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endif %}
{% if show_theme_popularity_badges and theme.popularity_bucket %}
<div class="badge-explanation">
<div class="flex items-start gap-2">
<span class="theme-badge {% if theme.popularity_bucket=='Very Common' %}badge-pop-vc{% elif theme.popularity_bucket=='Common' %}badge-pop-c{% elif theme.popularity_bucket=='Uncommon' %}badge-pop-u{% elif theme.popularity_bucket=='Niche' %}badge-pop-n{% elif theme.popularity_bucket=='Rare' %}badge-pop-r{% endif %} flex-shrink-0"
title="Popularity: {{ theme.popularity_bucket }}">
{{ theme.popularity_bucket }}
</span>
<div class="flex-1 text-sm text-gray-700 dark:text-gray-300">
<strong>Theme Popularity:</strong> {{ theme.popularity_bucket }}
<ul class="mt-1 ml-4 list-disc space-y-0.5 text-xs text-gray-600 dark:text-gray-400">
{% if theme.popularity_bucket == 'Very Common' %}
<li>Extremely popular theme seen in many commander decks</li>
<li>High adoption rate across diverse commanders and strategies</li>
<li>Well-established with extensive community support and resources</li>
{% elif theme.popularity_bucket == 'Common' %}
<li>Popular theme frequently used in commander deckbuilding</li>
<li>Strong adoption rate with good community support</li>
<li>Well-documented strategies and card synergies available</li>
{% elif theme.popularity_bucket == 'Uncommon' %}
<li>Moderately popular theme with regular usage</li>
<li>Decent adoption rate, particularly in specific archetypes</li>
<li>Some community resources and discussion available</li>
{% elif theme.popularity_bucket == 'Niche' %}
<li>Specialized theme with limited but dedicated following</li>
<li>Lower adoption rate, often used in specific deck strategies</li>
<li>May require more research to optimize effectively</li>
{% elif theme.popularity_bucket == 'Rare' %}
<li>Rarely used theme with minimal adoption</li>
<li>Very low usage rate across commander decks</li>
<li>May offer unique deckbuilding opportunities for brewers</li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endif %}
</div>
</details>
{% endif %}
<div class="synergy-section"> <div class="synergy-section">
<h4>Synergies {% if not uncapped %}(capped){% endif %}</h4> <h4>Synergies {% if not uncapped %}(capped){% endif %}</h4>
<div class="theme-synergies"> <div class="theme-synergies">
@ -46,7 +184,7 @@
{% if theme.example_cards %} {% if theme.example_cards %}
{% for c in theme.example_cards %} {% for c in theme.example_cards %}
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %} {% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
<div class="ex-card card-sample text-center" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}"> <div class="ex-card text-center" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" /> <img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
<div class="text-[11px] mt-1 whitespace-nowrap overflow-hidden text-ellipsis font-semibold card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div> <div class="text-[11px] mt-1 whitespace-nowrap overflow-hidden text-ellipsis font-semibold card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
</div> </div>

View file

@ -30,7 +30,17 @@
<tbody> <tbody>
{% for it in items %} {% for it in items %}
<tr hx-get="/themes/fragment/detail/{{ it.id }}" hx-target="#theme-detail" hx-swap="innerHTML" title="Click for details" class="theme-row" data-theme-id="{{ it.id }}" tabindex="0" role="option" aria-selected="false"> <tr hx-get="/themes/fragment/detail/{{ it.id }}" hx-target="#theme-detail" hx-swap="innerHTML" title="Click for details" class="theme-row" data-theme-id="{{ it.id }}" tabindex="0" role="option" aria-selected="false">
<td title="{{ it.short_description or '' }}">{% set q = request.query_params.get('q') %}{% set name = it.theme %}{% if q %}{% set ql = q.lower() %}{% set nl = name.lower() %}{% if ql in nl %}{% set start = nl.find(ql) %}{% set end = start + q|length %}<span class="trunc-name">{{ name[:start] }}<mark>{{ name[start:end] }}</mark>{{ name[end:] }}</span>{% else %}<span class="trunc-name">{{ name }}</span>{% endif %}{% else %}<span class="trunc-name">{{ name }}</span>{% endif %} {% if diagnostics and it.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback description"></span>{% endif %} <td title="{{ it.short_description or '' }}">{% set q = request.query_params.get('q') %}{% set name = it.theme %}{% if q %}{% set ql = q.lower() %}{% set nl = name.lower() %}{% if ql in nl %}{% set start = nl.find(ql) %}{% set end = start + q|length %}<span class="trunc-name">{{ name[:start] }}<mark>{{ name[start:end] }}</mark>{{ name[end:] }}</span>{% else %}<span class="trunc-name">{{ name }}</span>{% endif %}{% else %}<span class="trunc-name">{{ name }}</span>{% endif %}
{# Quality tier badge #}
{% if show_theme_quality_badges and it.quality_tier %}
<span class="theme-badge badge-quality-{{ it.quality_tier|lower }}" title="Quality: {{ it.quality_tier }} ({{ (it.quality_score * 100)|round|int }}%)" aria-label="Quality tier: {{ it.quality_tier }}">{{ it.quality_tier[0]|upper }}</span>
{% endif %}
{# Pool size badge #}
{% if show_theme_pool_badges and it.pool_tier %}
<span class="theme-badge badge-pool-{{ it.pool_tier|lower }}" title="Pool: {{ it.pool_tier }} (~{{ it.pool_size }} cards)" aria-label="Pool tier: {{ it.pool_tier }}">{{ it.pool_tier[0]|upper }}</span>
{% endif %}
{# Diagnostics-only badges #}
{% if diagnostics and it.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback description"></span>{% endif %}
{% if diagnostics and it.editorial_quality %} {% if diagnostics and it.editorial_quality %}
<span class="theme-badge badge-quality-{{ it.editorial_quality }}" title="Editorial quality: {{ it.editorial_quality }}">{{ it.editorial_quality[0]|upper }}</span> <span class="theme-badge badge-quality-{{ it.editorial_quality }}" title="Editorial quality: {{ it.editorial_quality }}">{{ it.editorial_quality[0]|upper }}</span>
{% endif %} {% endif %}
@ -38,7 +48,7 @@
<td>{% if it.primary_color %}<span aria-label="Primary color: {{ it.primary_color }}">{{ it.primary_color }}</span>{% endif %}</td> <td>{% if it.primary_color %}<span aria-label="Primary color: {{ it.primary_color }}">{{ it.primary_color }}</span>{% endif %}</td>
<td>{% if it.secondary_color %}<span aria-label="Secondary color: {{ it.secondary_color }}">{{ it.secondary_color }}</span>{% endif %}</td> <td>{% if it.secondary_color %}<span aria-label="Secondary color: {{ it.secondary_color }}">{{ it.secondary_color }}</span>{% endif %}</td>
<td> <td>
{% if it.popularity_bucket %} {% if show_theme_popularity_badges and it.popularity_bucket %}
<span class="theme-badge {% if it.popularity_bucket=='Very Common' %}badge-pop-vc{% elif it.popularity_bucket=='Common' %}badge-pop-c{% elif it.popularity_bucket=='Uncommon' %}badge-pop-u{% elif it.popularity_bucket=='Niche' %}badge-pop-n{% elif it.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ it.popularity_bucket }}">{{ it.popularity_bucket }}</span> <span class="theme-badge {% if it.popularity_bucket=='Very Common' %}badge-pop-vc{% elif it.popularity_bucket=='Common' %}badge-pop-c{% elif it.popularity_bucket=='Uncommon' %}badge-pop-u{% elif it.popularity_bucket=='Niche' %}badge-pop-n{% elif it.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ it.popularity_bucket }}">{{ it.popularity_bucket }}</span>
{% endif %} {% endif %}
</td> </td>

View file

@ -13,7 +13,20 @@
<ul class="list-none p-0 m-0 flex flex-col gap-2.5"> <ul class="list-none p-0 m-0 flex flex-col gap-2.5">
{% for it in items %} {% for it in items %}
<li class="theme-list-card"> <li class="theme-list-card">
<a href="/themes/{{ it.id }}" class="font-semibold text-sm no-underline text-[var(--text)]">{{ it.theme }}</a> <div class="flex justify-between items-start gap-2">
<a href="/themes/{{ it.id }}" class="font-semibold text-sm no-underline text-[var(--text)]">{{ it.theme }}</a>
<div class="flex gap-1">
{% if show_theme_quality_badges and it.quality_tier %}
<span class="theme-badge badge-quality-{{ it.quality_tier|lower }}" title="Quality: {{ it.quality_tier }} ({{ (it.quality_score * 100)|round|int }}%)">{{ it.quality_tier[0]|upper }}</span>
{% endif %}
{% if show_theme_pool_badges and it.pool_tier %}
<span class="theme-badge badge-pool-{{ it.pool_tier|lower }}" title="Pool: {{ it.pool_tier }} (~{{ it.pool_size }} cards)">{{ it.pool_tier[0]|upper }}</span>
{% endif %}
{% if show_theme_popularity_badges and it.popularity_bucket %}
<span class="theme-badge {% if it.popularity_bucket=='Very Common' %}badge-pop-vc{% elif it.popularity_bucket=='Common' %}badge-pop-c{% elif it.popularity_bucket=='Uncommon' %}badge-pop-u{% elif it.popularity_bucket=='Niche' %}badge-pop-n{% elif it.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ it.popularity_bucket }}">{{ it.popularity_bucket }}</span>
{% endif %}
</div>
</div>
{% if it.short_description %}<div class="text-xs opacity-85 mt-0.5">{{ it.short_description }}</div>{% endif %} {% if it.short_description %}<div class="text-xs opacity-85 mt-0.5">{{ it.short_description }}</div>{% endif %}
</li> </li>
{% endfor %} {% endfor %}

View file

@ -1,14 +1,5 @@
{ {
"themes": [ "themes": [
{
"id": "blood-counters",
"theme": " Blood Counters",
"synergies": [],
"primary_color": "Black",
"secondary_color": "Blue",
"popularity_bucket": "Rare",
"description": "Uses Blood tokens to loot, set up graveyard recursion, and trigger discard/madness payoffs."
},
{ {
"id": "plus1-plus1-counters", "id": "plus1-plus1-counters",
"theme": "+1/+1 Counters", "theme": "+1/+1 Counters",
@ -25594,7 +25585,6 @@
"Rat Kindred": 17, "Rat Kindred": 17,
"Spell Copy": 138, "Spell Copy": 138,
"Demon Kindred": 19, "Demon Kindred": 19,
" Blood Counters": 2,
"Blood Token": 4, "Blood Token": 4,
"Vampire Kindred": 29, "Vampire Kindred": 29,
"Ape Kindred": 2, "Ape Kindred": 2,
@ -26321,7 +26311,6 @@
"Faerie Kindred": 53, "Faerie Kindred": 53,
"Rat Kindred": 109, "Rat Kindred": 109,
"Spell Copy": 29, "Spell Copy": 29,
" Blood Counters": 4,
"Vampire Kindred": 357, "Vampire Kindred": 357,
"Ape Kindred": 3, "Ape Kindred": 3,
"Leviathan Kindred": 2, "Leviathan Kindred": 2,
@ -27189,7 +27178,6 @@
"Threshold": 13, "Threshold": 13,
"Mystic Kindred": 1, "Mystic Kindred": 1,
"Thrull Kindred": 1, "Thrull Kindred": 1,
" Blood Counters": 1,
"Faerie Kindred": 8, "Faerie Kindred": 8,
"Troll Kindred": 7, "Troll Kindred": 7,
"Pilot Kindred": 20, "Pilot Kindred": 20,
@ -27656,7 +27644,6 @@
"Faerie Kindred": 25, "Faerie Kindred": 25,
"Rat Kindred": 2, "Rat Kindred": 2,
"Spell Copy": 35, "Spell Copy": 35,
" Blood Counters": 1,
"Vampire Kindred": 4, "Vampire Kindred": 4,
"Ape Kindred": 30, "Ape Kindred": 30,
"Leviathan Kindred": 9, "Leviathan Kindred": 9,

View file

@ -24,6 +24,10 @@ services:
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
ENABLE_PRESETS: "0" # 1=show presets section ENABLE_PRESETS: "0" # 1=show presets section
SHOW_THEME_QUALITY_BADGES: "1" # 1=show quality 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_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide
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

@ -26,6 +26,10 @@ services:
ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags ENABLE_CUSTOM_THEMES: "1" # 1=expose Additional Themes panel for user-supplied tags
USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session USER_THEME_LIMIT: "8" # Maximum number of user-supplied supplemental themes stored in session
ENABLE_PRESETS: "0" # 1=show presets section ENABLE_PRESETS: "0" # 1=show presets section
SHOW_THEME_QUALITY_BADGES: "1" # 1=show quality 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_FILTERS: "1" # 1=show filter dropdowns/chips in theme catalog; 0=hide
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)