mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
feat: add theme quality, pool size, and popularity badges with filtering (#56)
This commit is contained in:
parent
03e2846882
commit
8efdc77c08
21 changed files with 1165 additions and 64 deletions
|
|
@ -47,6 +47,10 @@ SHOW_DIAGNOSTICS=1 # dockerhub: SHOW_DIAGNOSTICS="1"
|
|||
ENABLE_THEMES=1 # dockerhub: ENABLE_THEMES="1"
|
||||
ENABLE_CUSTOM_THEMES=1 # dockerhub: ENABLE_CUSTOM_THEMES="1"
|
||||
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_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
||||
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
||||
|
|
|
|||
38
CHANGELOG.md
38
CHANGELOG.md
|
|
@ -9,6 +9,44 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### 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
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -250,6 +250,10 @@ See `.env.example` for the full catalog. Common knobs:
|
|||
| `SHOW_DIAGNOSTICS` | `1` | Enable Diagnostics tools and overlays. |
|
||||
| `SHOW_COMMANDERS` | `1` | Expose the commander browser. |
|
||||
| `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. |
|
||||
| `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`). |
|
||||
|
|
|
|||
|
|
@ -275,6 +275,10 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl
|
|||
| `SHOW_COMMANDERS` | `1` | Enable the commander browser. |
|
||||
| `ENABLE_THEMES` | `1` | Keep the theme browser and selector active. |
|
||||
| `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. |
|
||||
| `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`). |
|
||||
|
|
|
|||
|
|
@ -2,6 +2,44 @@
|
|||
|
||||
## [Unreleased]
|
||||
### 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
|
||||
- **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
|
||||
|
|
@ -23,7 +61,7 @@
|
|||
- 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
|
||||
- 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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -59,6 +59,16 @@ class ThemeEntry(BaseModel):
|
|||
None,
|
||||
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')
|
||||
|
||||
|
|
|
|||
104
code/web/app.py
104
code/web/app.py
|
|
@ -175,6 +175,10 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
|
|||
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True)
|
||||
SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False)
|
||||
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'
|
||||
ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_GESTIONS"), 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,
|
||||
"allow_must_haves": ALLOW_MUST_HAVES,
|
||||
"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,
|
||||
"random_modes": RANDOM_MODES,
|
||||
"random_ui": RANDOM_UI,
|
||||
|
|
@ -2531,7 +2539,34 @@ async def diagnostics_home(request: Request) -> HTMLResponse:
|
|||
summary["colors"] = {}
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -2542,6 +2577,73 @@ async def diagnostics_perf(request: Request) -> HTMLResponse:
|
|||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
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 ---
|
||||
@app.post("/diagnostics/combos")
|
||||
async def diagnostics_combos(request: Request) -> JSONResponse:
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ async def theme_catalog_detail_page(theme_id: str, request: Request):
|
|||
entry = idx.slug_to_entry.get(slug)
|
||||
if not entry:
|
||||
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
|
||||
detail.pop('has_fallback_description', None)
|
||||
detail.pop('editorial_quality', None)
|
||||
|
|
@ -428,12 +428,16 @@ async def theme_list_fragment(
|
|||
async def theme_list_simple_fragment(
|
||||
request: Request,
|
||||
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),
|
||||
offset: int | None = Query(0, ge=0),
|
||||
):
|
||||
"""Lightweight list: only id, theme, short_description (for speed).
|
||||
|
||||
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
|
||||
t0 = _t.time()
|
||||
|
|
@ -445,17 +449,46 @@ async def theme_list_simple_fragment(
|
|||
total = 0
|
||||
if fast_items is not None:
|
||||
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
|
||||
if q:
|
||||
ql = q.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)
|
||||
slice_items = fast_items[off: off + lim]
|
||||
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({
|
||||
"id": e.get("id"),
|
||||
"theme": e.get("theme"),
|
||||
"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:
|
||||
# Fallback: load full index
|
||||
|
|
@ -463,7 +496,20 @@ async def theme_list_simple_fragment(
|
|||
idx = load_index()
|
||||
except FileNotFoundError:
|
||||
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)
|
||||
slice_slugs = slugs[off: off + lim]
|
||||
items_raw = summaries_for_slugs(idx, slice_slugs)
|
||||
|
|
@ -472,6 +518,11 @@ async def theme_list_simple_fragment(
|
|||
"id": it.get("id"),
|
||||
"theme": it.get("theme"),
|
||||
"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))
|
||||
resp = _templates.TemplateResponse(
|
||||
|
|
@ -511,7 +562,7 @@ async def theme_detail_fragment(
|
|||
return HTMLResponse("<div class='error'>Not found.</div>", status_code=404)
|
||||
diag = _diag_enabled() and bool(diagnostics)
|
||||
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:
|
||||
detail.pop('has_fallback_description', None)
|
||||
detail.pop('editorial_quality', None)
|
||||
|
|
@ -700,7 +751,7 @@ async def api_theme_detail(
|
|||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="theme_not_found")
|
||||
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:
|
||||
# Remove diagnostics-only fields
|
||||
detail.pop("has_fallback_description", None)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Responsibilities:
|
|||
- Provide summary & detail projections (with synergy segmentation).
|
||||
- NEW (Phase F perf): precompute summary dicts & lowercase haystacks, and
|
||||
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
|
||||
|
|
@ -69,6 +70,10 @@ class SlugThemeIndex(BaseModel):
|
|||
haystack_by_slug: Dict[str, str]
|
||||
primary_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
|
||||
yaml_mtime_max: float
|
||||
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)}"
|
||||
|
||||
|
||||
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:
|
||||
if not _needs_reload():
|
||||
return _CACHE["index"]
|
||||
|
|
@ -174,14 +253,92 @@ def load_index() -> SlugThemeIndex:
|
|||
haystack_by_slug: Dict[str, str] = {}
|
||||
primary_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:
|
||||
slug = slugify(t.theme)
|
||||
slug_to_entry[slug] = t
|
||||
summary = project_summary(t)
|
||||
summary_by_slug[slug] = summary
|
||||
# Will populate quality after calculation below
|
||||
summary_by_slug[slug] = {} # Populated after quality calculation
|
||||
haystack_by_slug[slug] = "|".join([t.theme] + t.synergies).lower()
|
||||
primary_color_by_slug[slug] = t.primary_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()
|
||||
idx = SlugThemeIndex(
|
||||
catalog=catalog,
|
||||
|
|
@ -191,6 +348,10 @@ def load_index() -> SlugThemeIndex:
|
|||
haystack_by_slug=haystack_by_slug,
|
||||
primary_color_by_slug=primary_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,
|
||||
yaml_mtime_max=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
|
||||
|
||||
|
||||
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
|
||||
desc = entry.description or ""
|
||||
short_desc = desc.strip()
|
||||
|
|
@ -298,6 +465,10 @@ def project_summary(entry: ThemeEntry) -> Dict[str, Any]:
|
|||
"popularity_bucket": entry.popularity_bucket,
|
||||
"deck_archetype": entry.deck_archetype,
|
||||
"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,
|
||||
"short_description": short_desc,
|
||||
"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)
|
||||
uncapped_synergies: Optional[List[str]] = None
|
||||
if uncapped:
|
||||
|
|
@ -330,7 +501,18 @@ def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, A
|
|||
full.append(s)
|
||||
seen.add(s)
|
||||
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({
|
||||
"curated_synergies": seg["curated"],
|
||||
"enforced_synergies": seg["enforced"],
|
||||
|
|
@ -449,6 +631,7 @@ def summaries_for_slugs(idx: SlugThemeIndex, slugs: Iterable[str]) -> List[Dict[
|
|||
for s in slugs:
|
||||
summ = idx.summary_by_slug.get(s)
|
||||
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
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -627,6 +627,10 @@ video {
|
|||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.mb-0\.5 {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
|
@ -659,6 +663,10 @@ video {
|
|||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.ml-6 {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
|
@ -763,8 +771,8 @@ video {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.min-w-\[160px\] {
|
||||
min-width: 160px;
|
||||
.min-w-\[140px\] {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.min-w-\[2\.5rem\] {
|
||||
|
|
@ -787,6 +795,10 @@ video {
|
|||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
|
@ -813,6 +825,10 @@ video {
|
|||
resize: both;
|
||||
}
|
||||
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.list-none {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
|
@ -885,6 +901,18 @@ video {
|
|||
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;
|
||||
}
|
||||
|
|
@ -897,6 +925,10 @@ video {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-\[10px\] {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
|
@ -921,6 +953,16 @@ video {
|
|||
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 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
||||
|
|
@ -934,6 +976,10 @@ video {
|
|||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.px-1\.5 {
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
|
|
@ -944,6 +990,11 @@ video {
|
|||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
|
|
@ -954,6 +1005,11 @@ video {
|
|||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.py-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -962,6 +1018,10 @@ video {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.text-\[10px\] {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.text-\[11px\] {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
|
@ -1018,6 +1078,16 @@ video {
|
|||
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 {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
|
@ -1030,6 +1100,10 @@ video {
|
|||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.opacity-60 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.opacity-70 {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
|
@ -4806,6 +4880,62 @@ img.lqip.loaded {
|
|||
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 {
|
||||
background: #4338ca;
|
||||
color: #fff;
|
||||
|
|
@ -4822,6 +4952,8 @@ img.lqip.loaded {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Popularity bucket badges */
|
||||
|
||||
.badge-pop-vc {
|
||||
background: #065f46;
|
||||
color: #fff;
|
||||
|
|
@ -5687,3 +5819,28 @@ footer.site-footer {
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2685,6 +2685,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
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 {
|
||||
background: #4338ca;
|
||||
color: #fff;
|
||||
|
|
@ -2701,6 +2749,7 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Popularity bucket badges */
|
||||
.badge-pop-vc {
|
||||
background: #065f46;
|
||||
color: #fff;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
<script>
|
||||
window.__telemetryEndpoint = '/telemetry/events';
|
||||
</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" />
|
||||
<style>
|
||||
/* Disable all transitions until page is loaded to prevent sidebar flash */
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
<section>
|
||||
<h2>Diagnostics</h2>
|
||||
<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">
|
||||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
<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;">System summary</summary>
|
||||
<div id="sysSummary" class="muted" style="margin-top:.5rem">Loading…</div>
|
||||
<div id="envFlags" style="margin-top:.5rem"></div>
|
||||
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div>
|
||||
<div id="themeSummary" style="margin-top:.5rem"></div>
|
||||
|
|
@ -13,9 +13,49 @@
|
|||
<div style="margin-top:.35rem">
|
||||
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</details>
|
||||
|
||||
{# 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>
|
||||
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
|
||||
{% if colors %}
|
||||
|
|
@ -70,25 +110,25 @@
|
|||
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
|
||||
{% endif %}
|
||||
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
|
||||
</div>
|
||||
<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">Dual-Commander diagnostics</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem;">Latest partner, partner-with, doctor, and background pairings with color sources.</div>
|
||||
</details>
|
||||
<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;">Dual-Commander diagnostics</summary>
|
||||
<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="partnerMetricsModes" class="muted" style="margin-top:.5rem;"></div>
|
||||
<div id="partnerColorSources" style="margin-top:.5rem;"></div>
|
||||
</div>
|
||||
<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">Performance (local)</h3>
|
||||
</details>
|
||||
<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;">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 style="display:flex; gap:1rem; flex-wrap:wrap">
|
||||
<div><strong>Scroll FPS:</strong> <span id="perf-fps">–</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>
|
||||
</div>
|
||||
<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">Combos & Synergies (ad-hoc)</h3>
|
||||
</details>
|
||||
<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;">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>
|
||||
<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">
|
||||
|
|
@ -96,21 +136,21 @@
|
|||
<small class="muted">Runs in diagnostics mode only.</small>
|
||||
</div>
|
||||
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
|
||||
</div>
|
||||
</details>
|
||||
{% if enable_pwa %}
|
||||
<div 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>
|
||||
<div id="pwaStatus" class="muted">Checking…</div>
|
||||
</div>
|
||||
<details class="card" style="background:#0f1115; 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;">PWA status</summary>
|
||||
<div id="pwaStatus" class="muted" style="margin-top:.5rem">Checking…</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
||||
<h3 style="margin-top:0">Error triggers</h3>
|
||||
<details class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
||||
<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">
|
||||
<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>
|
||||
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if show_logs %}
|
||||
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
|
||||
{% endif %}
|
||||
|
|
|
|||
159
code/web/templates/diagnostics/quality_dashboard.html
Normal file
159
code/web/templates/diagnostics/quality_dashboard.html
Normal 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 (<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 %}
|
||||
|
|
@ -8,7 +8,29 @@
|
|||
<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>
|
||||
<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>
|
||||
<select id="pop-filter" class="w-full text-[13px]">
|
||||
<option value="">All</option>
|
||||
|
|
@ -19,13 +41,78 @@
|
|||
<option>Rare</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button id="clear-search" class="btn btn-ghost text-xs" hidden>Clear</button>
|
||||
</div>
|
||||
<div id="quick-popularity" class="flex gap-1.5 flex-wrap mb-2">
|
||||
{% 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 %}
|
||||
|
||||
{% if show_theme_filters %}
|
||||
<div id="quick-filters" class="flex gap-2.5 flex-wrap mb-2">
|
||||
<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>
|
||||
{% 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 (<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 (<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="theme-results" aria-live="polite" aria-busy="true">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
@ -38,7 +125,11 @@
|
|||
const input = document.getElementById('theme-search');
|
||||
const resultsBox = document.getElementById('theme-search-results');
|
||||
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 qualityChips = document.querySelectorAll('.quality-chip');
|
||||
const poolChips = document.querySelectorAll('.pool-chip');
|
||||
const popChips = document.querySelectorAll('.pop-chip');
|
||||
const activeFilters = document.getElementById('active-filters');
|
||||
const resultsHost = document.getElementById('theme-results');
|
||||
|
|
@ -48,7 +139,9 @@
|
|||
function buildParams(){
|
||||
const params = new URLSearchParams();
|
||||
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');
|
||||
return params.toString();
|
||||
}
|
||||
|
|
@ -62,7 +155,9 @@
|
|||
function renderActive(){
|
||||
activeFilters.innerHTML='';
|
||||
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(){
|
||||
const ps = buildParams();
|
||||
|
|
@ -154,7 +249,18 @@
|
|||
fetch('/themes/fragment/detail/'+id,{cache:'reload'}).catch(()=>{});
|
||||
});
|
||||
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
|
||||
fetchList();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -18,10 +18,148 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
<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.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback generic description" aria-label="Fallback generic description">Fallback</span>{% endif %}
|
||||
</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">
|
||||
<h4>Synergies {% if not uncapped %}(capped){% endif %}</h4>
|
||||
<div class="theme-synergies">
|
||||
|
|
@ -46,7 +184,7 @@
|
|||
{% if theme.example_cards %}
|
||||
{% for c in theme.example_cards %}
|
||||
{% 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') }}" />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,17 @@
|
|||
<tbody>
|
||||
{% 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">
|
||||
<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 %}
|
||||
<span class="theme-badge badge-quality-{{ it.editorial_quality }}" title="Editorial quality: {{ it.editorial_quality }}">{{ it.editorial_quality[0]|upper }}</span>
|
||||
{% 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.secondary_color %}<span aria-label="Secondary color: {{ it.secondary_color }}">{{ it.secondary_color }}</span>{% endif %}</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>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,20 @@
|
|||
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
|
||||
{% for it in items %}
|
||||
<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 %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
{
|
||||
"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",
|
||||
"theme": "+1/+1 Counters",
|
||||
|
|
@ -25594,7 +25585,6 @@
|
|||
"Rat Kindred": 17,
|
||||
"Spell Copy": 138,
|
||||
"Demon Kindred": 19,
|
||||
" Blood Counters": 2,
|
||||
"Blood Token": 4,
|
||||
"Vampire Kindred": 29,
|
||||
"Ape Kindred": 2,
|
||||
|
|
@ -26321,7 +26311,6 @@
|
|||
"Faerie Kindred": 53,
|
||||
"Rat Kindred": 109,
|
||||
"Spell Copy": 29,
|
||||
" Blood Counters": 4,
|
||||
"Vampire Kindred": 357,
|
||||
"Ape Kindred": 3,
|
||||
"Leviathan Kindred": 2,
|
||||
|
|
@ -27189,7 +27178,6 @@
|
|||
"Threshold": 13,
|
||||
"Mystic Kindred": 1,
|
||||
"Thrull Kindred": 1,
|
||||
" Blood Counters": 1,
|
||||
"Faerie Kindred": 8,
|
||||
"Troll Kindred": 7,
|
||||
"Pilot Kindred": 20,
|
||||
|
|
@ -27656,7 +27644,6 @@
|
|||
"Faerie Kindred": 25,
|
||||
"Rat Kindred": 2,
|
||||
"Spell Copy": 35,
|
||||
" Blood Counters": 1,
|
||||
"Vampire Kindred": 4,
|
||||
"Ape Kindred": 30,
|
||||
"Leviathan Kindred": 9,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ services:
|
|||
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
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ services:
|
|||
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
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue