feat(preview): sampling, metrics, governance, server mana data

Preview endpoint + fast caches; curated pins + role quotas + rarity/overlap tuning; catalog+preview metrics; governance enforcement flags; server mana/color identity fields; docs/tests/scripts updated.
This commit is contained in:
matt 2025-09-23 09:19:23 -07:00
parent 8f47dfbb81
commit c4a7fc48ea
40 changed files with 6092 additions and 17312 deletions

View file

@ -39,6 +39,7 @@ ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1"
WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only)
############################
# Automation & Performance (Web)

View file

@ -47,6 +47,10 @@ jobs:
run: |
python code/scripts/validate_theme_catalog.py --strict-alias
- name: Fast path catalog presence & hash validation
run: |
python code/scripts/validate_theme_fast_path.py --strict-warn
- name: Fast determinism tests (random subset)
env:
CSV_FILES_DIR: csv_files/testdata

View file

@ -13,9 +13,35 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability.
## [Unreleased]
### Added
- ETag header for basic client-side caching of catalog fragments.
- Theme catalog performance optimizations: precomputed summary maps, lowercase search haystacks, memoized filtered slug cache (keyed by `(etag, params)`) for sub50ms warm queries.
- Theme preview endpoint: `GET /themes/api/theme/{id}/preview` (and HTML fragment) returning representative sample (curated examples, curated synergy examples, heuristic roles: payoff / enabler / support / wildcard / synthetic).
- Commander bias heuristics (color identity restriction, diminishing synergy overlap bonus, direct theme match bonus).
- Inmemory TTL cache (default 600s) for previews with build time tracking.
- Metrics endpoint `GET /themes/metrics` (diagnostics gated) exposing preview & catalog counters, cache stats, percentile build times.
- Governance metrics: `example_enforcement_active`, `example_enforce_threshold_pct` surfaced once curated coverage passes threshold (default 90%).
- Skeleton loading states for picker list, preview modal, and initial shell.
- Diagnostics flag `WEB_THEME_PICKER_DIAGNOSTICS=1` enabling fallback description flag, editorial quality badges, uncapped synergy toggle, YAML fetch, metrics endpoint.
- Cache bust hooks on catalog refresh & tagging completion clearing filter & preview caches (metrics include `preview_last_bust_at`).
- Optional filter cache prewarm (`WEB_THEME_FILTER_PREWARM=1`) priming common filter combinations; metrics include `filter_prewarmed`.
- Preview modal UX: role chips, condensed reasons line, hover tooltip with multiline heuristic reasons, export bar (CSV/JSON) honoring curated-only toggle.
- Server authoritative mana & color identity ingestion (exposes `mana_cost`, `color_identity_list`, `pip_colors`) replacing client-side parsing.
### Changed
- Picker list & API use optimized fast filtering path (`filter_slugs_fast`) replacing per-request linear scans.
- Preview sampling: curated examples pinned first, diversity quotas (~40% payoff / 40% enabler+support / 20% wildcard), synthetic placeholders only if underfilled.
- Sampling refinements: rarity diminishing weight, splash leniency (single off-color allowance with penalty for 45 color commanders), role saturation penalty, refined commander overlap scaling curve.
- Hover / DFC UX unified: single hover panel, overlay flip control (keyboard + persisted face), enlarged thumbnails (110px→165px→230px), activation limited to thumbnails.
- Removed legacy client-side mana & color identity parsers (now server authoritative fields included in preview items and export endpoints).
### Fixed
- Removed redundant template environment instantiation causing inconsistent navigation state.
- Ensured preview cache key includes catalog ETag to prevent stale sample reuse after catalog reload.
- Explicit cache bust after tagging/catalog rebuild prevents stale preview exposure.
### Editorial / Themes
- Enforce minimum example_commanders threshold (>=5) in CI (Phase D close-out). Lint now fails builds when a non-alias theme drops below threshold.
- Enforce minimum `example_commanders` threshold (>=5) in CI; lint fails builds when a non-alias theme drops below threshold.
- Added enforcement test `test_theme_editorial_min_examples_enforced.py` to guard regression.
- Governance workflow updated to pass `--enforce-min-examples` and set `EDITORIAL_MIN_EXAMPLES_ENFORCE=1`.
- Clarified lint script docstring and behavior around enforced minimums.

BIN
README.md

Binary file not shown.

View file

@ -3,17 +3,30 @@
## Unreleased (Draft)
### Added
- Editorial duplication suppression for example cards: `--common-card-threshold` (default 0.18) and `--print-dup-metrics` flags in `synergy_promote_fill.py` to reduce over-represented staples and surface diverse thematic examples.
- Optional `description_fallback_summary` block (enabled via `EDITORIAL_INCLUDE_FALLBACK_SUMMARY=1`) capturing specialization KPIs: generic vs specialized description counts and top generic holdouts.
- Theme picker performance: precomputed summary projections + lowercase haystacks and memoized filtered slug cache (keyed by (etag, q, archetype, bucket, colors)) for sub50ms typical list queries on warm path.
- Skeleton loading UI for theme picker list, preview modal, and initial shell.
- Theme preview endpoint (`/themes/api/theme/{id}/preview` + HTML fragment) returning representative sample with roles (payoff/enabler/support/wildcard/example/curated_synergy/synthetic).
- Commander bias heuristics in preview sampling (color identity filtering + overlap/theme bonuses) for context-aware suggestions.
- Inmemory TTL (600s) preview cache with metrics (requests, cache hits, average build ms) exposed at diagnostics endpoint.
- Web UI: Double-faced card (DFC) hover support with single-image overlay flip control (top-left button, keyboard (Enter/Space/F), aria-live), persisted face (localStorage), and immediate refresh post-flip.
- Diagnostics flag `WEB_THEME_PICKER_DIAGNOSTICS=1` gating fallback description flag, editorial quality badges, uncapped synergy lists, raw YAML fetch, and metrics endpoint (`/themes/metrics`).
- Catalog & preview metrics endpoint combining filter + preview counters & cache stats.
- Performance headers on list & API responses: `X-ThemeCatalog-Filter-Duration-ms` and `ETag` for conditional requests.
- Cache bust hooks tied to catalog refresh & tagging completion clear filter/preview caches (metrics now include last bust timestamps).
- Governance metrics: `example_enforcement_active`, `example_enforce_threshold_pct` (threshold default 90%) signal when curated coverage enforcement is active.
- Server authoritative mana & color identity fields (`mana_cost`, `color_identity_list`, `pip_colors`) included in preview/export; legacy client parsers removed.
### Changed
- Terminology migration: `provenance` renamed to `metadata_info` across catalog JSON, per-theme YAML, models, and tests. Builder writes `metadata_info`; legacy `provenance` key still accepted temporarily.
- Preview assembly now pins curated `example_cards` then `synergy_example_cards` before heuristic sampling with diversity quotas (~40% payoff, 40% enabler/support, 20% wildcard) and synthetic placeholders only when underfilled.
- List & API filtering route migrated to optimized path avoiding repeated concatenation / casefolding work each request.
- Hover system consolidated to one global panel; removed fragment-specific duplicate & legacy large-image hover. Thumbnails enlarged & unified (110px → 165px → 230px). Hover activation limited to thumbnails; stability improved (no dismissal over flip control); DFC markup simplified to single <img> with opacity transition.
### Deprecated
- Legacy `provenance` key retained as read-only alias; warning emitted if both keys present (suppress via `SUPPRESS_PROVENANCE_DEPRECATION=1`). Planned removal: v2.4.0.
- (None new)
### Fixed
- Schema evolution adjustments to accept per-theme `metadata_info` and optional fallback summary without triggering validation failures.
- Resolved duplicate template environment instantiation causing inconsistent navigation globals in picker fragments.
- Ensured preview cache key includes catalog ETag preventing stale samples after catalog reload.
---

View file

@ -794,13 +794,40 @@ def build_catalog(limit: int, verbose: bool) -> Dict[str, Any]:
entries.append(entry)
# Renamed from 'provenance' to 'metadata_info' (migration phase)
# Compute deterministic hash of YAML catalog + synergy_cap for drift detection
import hashlib as _hashlib # local import to avoid top-level cost
def _catalog_hash() -> str:
h = _hashlib.sha256()
# Stable ordering: sort by display_name then key ordering inside dict for a subset of stable fields
for name in sorted(yaml_catalog.keys()):
yobj = yaml_catalog[name]
try:
# Compose a tuple of fields that should reflect editorial drift
payload = (
getattr(yobj, 'id', ''),
getattr(yobj, 'display_name', ''),
tuple(getattr(yobj, 'curated_synergies', []) or []),
tuple(getattr(yobj, 'enforced_synergies', []) or []),
tuple(getattr(yobj, 'example_commanders', []) or []),
tuple(getattr(yobj, 'example_cards', []) or []),
getattr(yobj, 'deck_archetype', None),
getattr(yobj, 'popularity_hint', None),
getattr(yobj, 'description', None),
getattr(yobj, 'editorial_quality', None),
)
h.update(repr(payload).encode('utf-8'))
except Exception:
continue
h.update(str(synergy_cap).encode('utf-8'))
return h.hexdigest()
metadata_info = {
'mode': 'merge',
'generated_at': time.strftime('%Y-%m-%dT%H:%M:%S'),
'curated_yaml_files': len(yaml_catalog),
'synergy_cap': synergy_cap,
'inference': 'pmi',
'version': 'phase-b-merge-v1'
'version': 'phase-b-merge-v1',
'catalog_hash': _catalog_hash(),
}
# Optional popularity analytics export for Phase D metrics collection
if os.environ.get('EDITORIAL_POP_EXPORT'):

View file

@ -0,0 +1,105 @@
"""CLI utility: snapshot preview metrics and emit summary/top slow themes.
Usage (from repo root virtualenv):
python -m code.scripts.preview_metrics_snapshot --limit 10 --output logs/preview_metrics_snapshot.json
Fetches /themes/metrics (requires WEB_THEME_PICKER_DIAGNOSTICS=1) and writes a compact JSON plus
human-readable summary to stdout.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any, Dict
import urllib.request
import urllib.error
DEFAULT_URL = "http://localhost:8000/themes/metrics"
def fetch_metrics(url: str) -> Dict[str, Any]:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp: # nosec B310 (local trusted)
data = resp.read().decode("utf-8", "replace")
try:
return json.loads(data) # type: ignore[return-value]
except json.JSONDecodeError as e: # pragma: no cover - unlikely if server OK
raise SystemExit(f"Invalid JSON from metrics endpoint: {e}\nRaw: {data[:400]}")
def summarize(metrics: Dict[str, Any], top_n: int) -> Dict[str, Any]:
preview = (metrics.get("preview") or {}) if isinstance(metrics, dict) else {}
per_theme = preview.get("per_theme") or {}
# Compute top slow themes by avg_ms
items = []
for slug, info in per_theme.items():
if not isinstance(info, dict):
continue
avg = info.get("avg_ms")
if isinstance(avg, (int, float)):
items.append((slug, float(avg), info))
items.sort(key=lambda x: x[1], reverse=True)
top = items[:top_n]
return {
"preview_requests": preview.get("preview_requests"),
"preview_cache_hits": preview.get("preview_cache_hits"),
"preview_avg_build_ms": preview.get("preview_avg_build_ms"),
"preview_p95_build_ms": preview.get("preview_p95_build_ms"),
"preview_ttl_seconds": preview.get("preview_ttl_seconds"),
"editorial_curated_vs_sampled_pct": preview.get("editorial_curated_vs_sampled_pct"),
"top_slowest": [
{
"slug": slug,
"avg_ms": avg,
"p95_ms": info.get("p95_ms"),
"builds": info.get("builds"),
"requests": info.get("requests"),
"avg_curated_pct": info.get("avg_curated_pct"),
}
for slug, avg, info in top
],
}
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description="Snapshot preview metrics")
ap.add_argument("--url", default=DEFAULT_URL, help="Metrics endpoint URL (default: %(default)s)")
ap.add_argument("--limit", type=int, default=10, help="Top N slow themes to include (default: %(default)s)")
ap.add_argument("--output", type=Path, help="Optional output JSON file for snapshot")
ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary (still writes file if --output)")
args = ap.parse_args(argv)
try:
raw = fetch_metrics(args.url)
except urllib.error.URLError as e:
print(f"ERROR: Failed fetching metrics endpoint: {e}", file=sys.stderr)
return 2
summary = summarize(raw, args.limit)
snapshot = {
"captured_at": int(time.time()),
"source": args.url,
"summary": summary,
}
if args.output:
try:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(snapshot, indent=2, sort_keys=True), encoding="utf-8")
except Exception as e: # pragma: no cover
print(f"ERROR: writing snapshot file failed: {e}", file=sys.stderr)
return 3
if not args.quiet:
print("Preview Metrics Snapshot:")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Fast path theme catalog presence & schema sanity validator.
Checks:
1. theme_list.json exists.
2. Loads JSON and ensures top-level keys present: themes (list), metadata_info (dict).
3. Basic field contract for each theme: id, theme, synergies (list), description.
4. Enforces presence of catalog_hash inside metadata_info for drift detection.
5. Optionally validates against Pydantic models if available (best effort).
Exit codes:
0 success
1 structural failure / missing file
2 partial validation warnings elevated via --strict
"""
from __future__ import annotations
import sys
import json
import argparse
import pathlib
import typing as t
THEME_LIST_PATH = pathlib.Path('config/themes/theme_list.json')
class Problem:
def __init__(self, level: str, message: str):
self.level = level
self.message = message
def __repr__(self):
return f"{self.level.upper()}: {self.message}"
def load_json(path: pathlib.Path) -> t.Any:
try:
return json.loads(path.read_text(encoding='utf-8') or '{}')
except FileNotFoundError:
raise
except Exception as e: # pragma: no cover
raise RuntimeError(f"parse_error: {e}")
def validate(data: t.Any) -> list[Problem]:
probs: list[Problem] = []
if not isinstance(data, dict):
probs.append(Problem('error','top-level not an object'))
return probs
themes = data.get('themes')
if not isinstance(themes, list) or not themes:
probs.append(Problem('error','themes list missing or empty'))
meta = data.get('metadata_info')
if not isinstance(meta, dict):
probs.append(Problem('error','metadata_info missing or not object'))
else:
if not meta.get('catalog_hash'):
probs.append(Problem('error','metadata_info.catalog_hash missing'))
if not meta.get('generated_at'):
probs.append(Problem('warn','metadata_info.generated_at missing'))
# Per theme spot check (limit to first 50 to keep CI snappy)
for i, th in enumerate(themes[:50] if isinstance(themes, list) else []):
if not isinstance(th, dict):
probs.append(Problem('error', f'theme[{i}] not object'))
continue
if not th.get('id'):
probs.append(Problem('error', f'theme[{i}] id missing'))
if not th.get('theme'):
probs.append(Problem('error', f'theme[{i}] theme missing'))
syns = th.get('synergies')
if not isinstance(syns, list) or not syns:
probs.append(Problem('warn', f'theme[{i}] synergies empty or not list'))
if 'description' not in th:
probs.append(Problem('warn', f'theme[{i}] description missing'))
return probs
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description='Validate fast path theme catalog build presence & schema.')
ap.add_argument('--strict-warn', action='store_true', help='Promote warnings to errors (fail CI).')
args = ap.parse_args(argv)
if not THEME_LIST_PATH.exists():
print('ERROR: theme_list.json missing at expected path.', file=sys.stderr)
return 1
try:
data = load_json(THEME_LIST_PATH)
except FileNotFoundError:
print('ERROR: theme_list.json missing.', file=sys.stderr)
return 1
except Exception as e:
print(f'ERROR: failed parsing theme_list.json: {e}', file=sys.stderr)
return 1
problems = validate(data)
errors = [p for p in problems if p.level=='error']
warns = [p for p in problems if p.level=='warn']
for p in problems:
stream = sys.stderr if p.level!='info' else sys.stdout
print(repr(p), file=stream)
if errors:
return 1
if args.strict_warn and warns:
return 2
print(f"Fast path validation ok: {len(errors)} errors, {len(warns)} warnings. Checked {min(len(data.get('themes', [])),50)} themes.")
return 0
if __name__ == '__main__':
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,91 @@
"""Generate warm preview traffic to populate theme preview cache & metrics.
Usage:
python -m code.scripts.warm_preview_traffic --count 25 --repeats 2 \
--base-url http://localhost:8000 --delay 0.05
Requirements:
- FastAPI server running locally exposing /themes endpoints
- WEB_THEME_PICKER_DIAGNOSTICS=1 so /themes/metrics is accessible
Strategy:
1. Fetch /themes/fragment/list?limit=COUNT to obtain HTML table.
2. Extract theme slugs via regex on data-theme-id attributes.
3. Issue REPEATS preview fragment requests per slug in order.
4. Print simple timing / status summary.
This script intentionally uses stdlib only (urllib, re, time) to avoid extra deps.
"""
from __future__ import annotations
import argparse
import re
import time
import urllib.request
import urllib.error
from typing import List
LIST_PATH = "/themes/fragment/list"
PREVIEW_PATH = "/themes/fragment/preview/{slug}"
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers={"User-Agent": "warm-preview/1"})
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 (local trusted)
return resp.read().decode("utf-8", "replace")
def extract_slugs(html: str, limit: int) -> List[str]:
slugs = []
for m in re.finditer(r'data-theme-id="([^"]+)"', html):
s = m.group(1).strip()
if s and s not in slugs:
slugs.append(s)
if len(slugs) >= limit:
break
return slugs
def warm(base_url: str, count: int, repeats: int, delay: float) -> None:
list_url = f"{base_url}{LIST_PATH}?limit={count}&offset=0"
print(f"[warm] Fetching list: {list_url}")
try:
html = fetch(list_url)
except urllib.error.URLError as e: # pragma: no cover
raise SystemExit(f"Failed fetching list: {e}")
slugs = extract_slugs(html, count)
if not slugs:
raise SystemExit("No theme slugs extracted cannot warm.")
print(f"[warm] Extracted {len(slugs)} slugs: {', '.join(slugs[:8])}{'...' if len(slugs)>8 else ''}")
total_requests = 0
start = time.time()
for r in range(repeats):
print(f"[warm] Pass {r+1}/{repeats}")
for slug in slugs:
url = f"{base_url}{PREVIEW_PATH.format(slug=slug)}"
try:
fetch(url)
except Exception as e: # pragma: no cover
print(f" [warn] Failed {slug}: {e}")
else:
total_requests += 1
if delay:
time.sleep(delay)
dur = time.time() - start
print(f"[warm] Completed {total_requests} preview requests in {dur:.2f}s ({total_requests/dur if dur>0 else 0:.1f} rps)")
print("[warm] Done. Now run metrics snapshot to capture warm p95.")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description="Generate warm preview traffic")
ap.add_argument("--base-url", default="http://localhost:8000", help="Base URL (default: %(default)s)")
ap.add_argument("--count", type=int, default=25, help="Number of distinct theme slugs to warm (default: %(default)s)")
ap.add_argument("--repeats", type=int, default=2, help="Repeat passes over slugs (default: %(default)s)")
ap.add_argument("--delay", type=float, default=0.05, help="Delay between requests in seconds (default: %(default)s)")
args = ap.parse_args(argv)
warm(args.base_url.rstrip("/"), args.count, args.repeats, args.delay)
return 0
if __name__ == "__main__": # pragma: no cover
import sys
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,30 @@
import json
from code.web.routes.themes import _load_fast_theme_list
def test_fast_theme_list_derives_ids(monkeypatch, tmp_path):
# Create a minimal theme_list.json without explicit 'id' fields to simulate current build output
data = {
"themes": [
{"theme": "+1/+1 Counters", "description": "Foo desc that is a bit longer to ensure trimming works properly and demonstrates snippet logic."},
{"theme": "Artifacts", "description": "Artifacts matter deck."},
],
"generated_from": "merge"
}
# Write to a temporary file and monkeypatch THEME_LIST_PATH to point there
theme_json = tmp_path / 'theme_list.json'
theme_json.write_text(json.dumps(data), encoding='utf-8')
from code.web.routes import themes as themes_module
monkeypatch.setattr(themes_module, 'THEME_LIST_PATH', theme_json)
lst = _load_fast_theme_list()
assert lst is not None
# Should derive slug ids
ids = {e['id'] for e in lst}
assert 'plus1-plus1-counters' in ids
assert 'artifacts' in ids
# Should generate short_description
for e in lst:
assert 'short_description' in e
assert e['short_description']

View file

@ -0,0 +1,20 @@
import json
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
def test_preview_includes_curated_examples_regression():
"""Regression test (2025-09-20): After P2 changes the preview lost curated
example cards because theme_list.json lacks example_* arrays. We added YAML
fallback in project_detail; ensure at least one 'example' role appears for
a theme known to have example_cards in its YAML (aggro.yml)."""
client = TestClient(app)
r = client.get('/themes/api/theme/aggro/preview?limit=12')
assert r.status_code == 200, r.text
data = r.json()
assert data.get('ok') is True
sample = data.get('preview', {}).get('sample', [])
# Collect roles
roles = { (it.get('roles') or [''])[0] for it in sample }
assert 'example' in roles, f"expected at least one curated example card role; roles present: {roles} sample={json.dumps(sample, indent=2)[:400]}"

View file

@ -0,0 +1,22 @@
from fastapi.testclient import TestClient
from code.web.app import app
def test_preview_error_rate_metrics(monkeypatch):
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
client = TestClient(app)
# Trigger one preview to ensure request counter increments
themes_resp = client.get('/themes/api/themes?limit=1')
assert themes_resp.status_code == 200
theme_id = themes_resp.json()['items'][0]['id']
pr = client.get(f'/themes/fragment/preview/{theme_id}')
assert pr.status_code == 200
# Simulate two client fetch error structured log events
for _ in range(2):
r = client.post('/themes/log', json={'event':'preview_fetch_error'})
assert r.status_code == 200
metrics = client.get('/themes/metrics').json()
assert metrics['ok'] is True
preview_block = metrics['preview']
assert 'preview_client_fetch_errors' in preview_block
assert preview_block['preview_client_fetch_errors'] >= 2
assert 'preview_error_rate_pct' in preview_block

View file

@ -0,0 +1,35 @@
from fastapi.testclient import TestClient
from code.web.app import app
def test_preview_metrics_percentiles_present(monkeypatch):
# Enable diagnostics for metrics endpoint
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
# Force logging on (not required but ensures code path safe)
monkeypatch.setenv('WEB_THEME_PREVIEW_LOG', '0')
client = TestClient(app)
# Hit a few previews to generate durations
# We need an existing theme id; fetch list API first
r = client.get('/themes/api/themes?limit=3')
assert r.status_code == 200, r.text
data = r.json()
# API returns 'items' not 'themes'
assert 'items' in data
themes = data['items']
assert themes, 'Expected at least one theme for metrics test'
theme_id = themes[0]['id']
for _ in range(3):
pr = client.get(f'/themes/fragment/preview/{theme_id}')
assert pr.status_code == 200
mr = client.get('/themes/metrics')
assert mr.status_code == 200, mr.text
metrics = mr.json()
assert metrics['ok'] is True
per_theme = metrics['preview']['per_theme']
# pick first entry in per_theme stats
# Validate new percentile fields exist (p50_ms, p95_ms) and are numbers
any_entry = next(iter(per_theme.values())) if per_theme else None
assert any_entry, 'Expected at least one per-theme metrics entry'
assert 'p50_ms' in any_entry and 'p95_ms' in any_entry, any_entry
assert isinstance(any_entry['p50_ms'], (int, float))
assert isinstance(any_entry['p95_ms'], (int, float))

View file

@ -0,0 +1,13 @@
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
def test_minimal_variant_hides_controls_and_headers():
client = TestClient(app)
r = client.get('/themes/fragment/preview/aggro?suppress_curated=1&minimal=1')
assert r.status_code == 200
html = r.text
assert 'Curated Only' not in html
assert 'Commander Overlap & Diversity Rationale' not in html
# Ensure sample cards still render
assert 'card-sample' in html

View file

@ -0,0 +1,17 @@
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
def test_preview_fragment_suppress_curated_removes_examples():
client = TestClient(app)
# Get HTML fragment with suppress_curated
r = client.get('/themes/fragment/preview/aggro?suppress_curated=1&limit=14')
assert r.status_code == 200
html = r.text
# Should not contain group label Curated Examples
assert 'Curated Examples' not in html
# Should still contain payoff/enabler group labels
assert 'Payoffs' in html or 'Enablers & Support' in html
# No example role chips: role-example occurrences removed
# Ensure no rendered span with curated example role (avoid style block false positive)
assert '<span class="mini-badge role-example"' not in html

View file

@ -0,0 +1,204 @@
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from code.web.app import app # type: ignore
# Ensure project root on sys.path for absolute imports
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
CATALOG_PATH = ROOT / 'config' / 'themes' / 'theme_list.json'
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_list_basic_ok():
client = TestClient(app)
r = client.get('/themes/api/themes')
assert r.status_code == 200
data = r.json()
assert data['ok'] is True
assert 'items' in data and isinstance(data['items'], list)
if data['items']:
sample = data['items'][0]
assert 'id' in sample and 'theme' in sample
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_list_query_substring():
client = TestClient(app)
r = client.get('/themes/api/themes', params={'q': 'Counters'})
assert r.status_code == 200
data = r.json()
assert all('Counters'.lower() in ('|'.join(it.get('synergies', []) + [it['theme']]).lower()) for it in data['items']) or not data['items']
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_list_filter_bucket_and_archetype():
client = TestClient(app)
base = client.get('/themes/api/themes').json()
if not base['items']:
pytest.skip('No themes to filter')
# Find first item with both bucket & archetype
candidate = None
for it in base['items']:
if it.get('popularity_bucket') and it.get('deck_archetype'):
candidate = it
break
if not candidate:
pytest.skip('No item with bucket+archetype to test')
r = client.get('/themes/api/themes', params={'bucket': candidate['popularity_bucket']})
assert r.status_code == 200
data_bucket = r.json()
assert all(i.get('popularity_bucket') == candidate['popularity_bucket'] for i in data_bucket['items'])
r2 = client.get('/themes/api/themes', params={'archetype': candidate['deck_archetype']})
assert r2.status_code == 200
data_arch = r2.json()
assert all(i.get('deck_archetype') == candidate['deck_archetype'] for i in data_arch['items'])
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_fragment_endpoints():
client = TestClient(app)
# Page
pg = client.get('/themes/picker')
assert pg.status_code == 200 and 'Theme Catalog' in pg.text
# List fragment
frag = client.get('/themes/fragment/list')
assert frag.status_code == 200
# Snippet hover presence (short_description used as title attribute on first theme cell if available)
if '<table>' in frag.text:
assert 'title="' in frag.text # coarse check; ensures at least one title attr present for snippet
# If there is at least one row, request detail fragment
base = client.get('/themes/api/themes').json()
if base['items']:
tid = base['items'][0]['id']
dfrag = client.get(f'/themes/fragment/detail/{tid}')
assert dfrag.status_code == 200
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_detail_ok_and_not_found():
client = TestClient(app)
listing = client.get('/themes/api/themes').json()
if not listing['items']:
pytest.skip('No themes to test detail')
first_id = listing['items'][0]['id']
r = client.get(f'/themes/api/theme/{first_id}')
assert r.status_code == 200
detail = r.json()['theme']
assert detail['id'] == first_id
r404 = client.get('/themes/api/theme/does-not-exist-xyz')
assert r404.status_code == 404
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_diagnostics_gating(monkeypatch):
client = TestClient(app)
# Without flag -> diagnostics fields absent
r = client.get('/themes/api/themes', params={'diagnostics': '1'})
sample = r.json()['items'][0] if r.json()['items'] else {}
assert 'has_fallback_description' not in sample
# Enable flag
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
r2 = client.get('/themes/api/themes', params={'diagnostics': '1'})
sample2 = r2.json()['items'][0] if r2.json()['items'] else {}
if sample2:
assert 'has_fallback_description' in sample2
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_uncapped_requires_diagnostics(monkeypatch):
client = TestClient(app)
listing = client.get('/themes/api/themes').json()
if not listing['items']:
pytest.skip('No themes available')
tid = listing['items'][0]['id']
# Request uncapped without diagnostics -> should not include
d = client.get(f'/themes/api/theme/{tid}', params={'uncapped': '1'}).json()['theme']
assert 'uncapped_synergies' not in d
# Enable diagnostics
monkeypatch.setenv('WEB_THEME_PICKER_DIAGNOSTICS', '1')
d2 = client.get(f'/themes/api/theme/{tid}', params={'diagnostics': '1', 'uncapped': '1'}).json()['theme']
# Uncapped may equal capped if no difference, but key must exist
assert 'uncapped_synergies' in d2
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_preview_endpoint_basic():
client = TestClient(app)
listing = client.get('/themes/api/themes').json()
if not listing['items']:
pytest.skip('No themes available')
tid = listing['items'][0]['id']
preview = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 5}).json()
assert preview['ok'] is True
sample = preview['preview']['sample']
assert len(sample) <= 5
# Scores should be non-increasing for first curated entries (simple heuristic)
scores = [it['score'] for it in sample]
assert all(isinstance(s, (int, float)) for s in scores)
# Synthetic placeholders (if any) should have role 'synthetic'
for it in sample:
assert 'roles' in it and isinstance(it['roles'], list)
# Color filter invocation (may reduce or keep size; ensure no crash)
preview_color = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 4, 'colors': 'U'}).json()
assert preview_color['ok'] is True
# Fragment version
frag = client.get(f'/themes/fragment/preview/{tid}')
assert frag.status_code == 200
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_preview_commander_bias(): # lightweight heuristic validation
client = TestClient(app)
listing = client.get('/themes/api/themes').json()
if not listing['items']:
pytest.skip('No themes available')
tid = listing['items'][0]['id']
# Use an arbitrary commander name depending on dataset may not be found; test tolerant
commander_name = 'Atraxa, Praetors Voice' # attempt full name; if absent test remains soft
preview = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 6, 'commander': commander_name}).json()
assert preview['ok'] is True
sample = preview['preview']['sample']
# If commander card was discovered at least one item should have commander_bias reason
any_commander_reason = any('commander_bias' in it.get('reasons', []) for it in sample)
# It's acceptable if not found (dataset subset) but reasons structure must exist
assert all('reasons' in it for it in sample)
# Soft assertion (no failure if commander not present) if discovered we assert overlap marker
if any_commander_reason:
assert any('commander_overlap' in it.get('reasons', []) for it in sample)
@pytest.mark.skipif(not CATALOG_PATH.exists(), reason="theme catalog missing")
def test_preview_curated_synergy_ordering():
"""Curated synergy example cards (role=curated_synergy) must appear after role=example
cards but before any sampled payoff/enabler/support/wildcard entries.
"""
client = TestClient(app)
listing = client.get('/themes/api/themes').json()
if not listing['items']:
pytest.skip('No themes available')
tid = listing['items'][0]['id']
preview = client.get(f'/themes/api/theme/{tid}/preview', params={'limit': 12}).json()
assert preview['ok'] is True
sample = preview['preview']['sample']
roles_sequence = [it['roles'][0] if it.get('roles') else None for it in sample]
if 'curated_synergy' not in roles_sequence:
pytest.skip('No curated synergy cards present in sample (data-dependent)')
first_non_example_index = None
first_curated_synergy_index = None
first_sampled_index = None
sampled_roles = {'payoff', 'enabler', 'support', 'wildcard'}
for idx, role in enumerate(roles_sequence):
if role != 'example' and first_non_example_index is None:
first_non_example_index = idx
if role == 'curated_synergy' and first_curated_synergy_index is None:
first_curated_synergy_index = idx
if role in sampled_roles and first_sampled_index is None:
first_sampled_index = idx
# Ensure ordering: examples (if any) -> curated_synergy -> sampled roles
if first_curated_synergy_index is not None and first_sampled_index is not None:
assert first_curated_synergy_index < first_sampled_index

View file

@ -0,0 +1,247 @@
"""Tests covering Section H (Testing Gaps) & related Phase F items.
These are backend-oriented approximations for browser behaviors. Where full
JS execution would be required (keyboard event dispatch, sessionStorage), we
simulate or validate server produced HTML attributes / ordering contracts.
Contained tests:
- test_fast_path_load_time: ensure catalog list fragment renders quickly using
fixture dataset (budget <= 120ms on CI hardware; relaxed if env override)
- test_colors_filter_constraint: applying colors=G restricts primary/secondary
colors to subset including 'G'
- test_preview_placeholder_fill: themes with insufficient real cards are
padded with synthetic placeholders (role synthetic & name bracketed)
- test_preview_cache_hit_timing: second call served from cache faster (uses
monkeypatch to force _now progression minimal)
- test_navigation_state_preservation_roundtrip: simulate list fetch then
detail fetch and ensure detail HTML contains theme id while list fragment
params persist in constructed URL logic (server side approximation)
- test_mana_cost_parser_variants: port of client JS mana parser implemented
in Python to validate hybrid / phyrexian / X handling does not crash.
NOTE: Pure keyboard navigation & sessionStorage cache skip paths require a
JS runtime; we assert presence of required attributes (tabindex, role=option)
as a smoke proxy until an integration (playwright) layer is added.
"""
from __future__ import annotations
import os
import re
import time
from typing import List
import pytest
from fastapi.testclient import TestClient
def _get_app(): # local import to avoid heavy import cost if file unused
from code.web.app import app # type: ignore
return app
@pytest.fixture(scope="module")
def client():
# Enable diagnostics to allow /themes/metrics access if gated
os.environ.setdefault("WEB_THEME_PICKER_DIAGNOSTICS", "1")
return TestClient(_get_app())
def test_fast_path_load_time(client):
# First load may include startup warm logic; allow generous budget, tighten later in CI ratchet
budget_ms = int(os.getenv("TEST_THEME_FAST_PATH_BUDGET_MS", "2500"))
t0 = time.perf_counter()
r = client.get("/themes/fragment/list?limit=20")
dt_ms = (time.perf_counter() - t0) * 1000
assert r.status_code == 200
# Basic sanity: table rows present
assert "theme-row" in r.text
assert dt_ms <= budget_ms, f"Fast path list fragment exceeded budget {dt_ms:.2f}ms > {budget_ms}ms"
def test_colors_filter_constraint(client):
r = client.get("/themes/fragment/list?limit=50&colors=G")
assert r.status_code == 200
rows = [m.group(0) for m in re.finditer(r"<tr[^>]*class=\"theme-row\"[\s\S]*?</tr>", r.text)]
assert rows, "Expected some rows for colors filter"
greenish = 0
considered = 0
for row in rows:
tds = re.findall(r"<td>(.*?)</td>", row)
if len(tds) < 3:
continue
primary = tds[1]
secondary = tds[2]
if primary or secondary:
considered += 1
if ("G" in primary) or ("G" in secondary):
greenish += 1
# Expect at least half of colored themes to include G (soft assertion due to multi-color / secondary logic on backend)
if considered:
assert greenish / considered >= 0.5, f"Expected >=50% green presence, got {greenish}/{considered}"
def test_preview_placeholder_fill(client):
# Find a theme likely to have low card pool by requesting high limit and then checking for synthetic placeholders '['
# Use first theme id from list fragment
list_html = client.get("/themes/fragment/list?limit=1").text
m = re.search(r'data-theme-id=\"([^\"]+)\"', list_html)
assert m, "Could not extract theme id"
theme_id = m.group(1)
# Request preview with high limit to likely force padding
pv = client.get(f"/themes/fragment/preview/{theme_id}?limit=30")
assert pv.status_code == 200
# Synthetic placeholders appear as names inside brackets (server template), search raw HTML
bracketed = re.findall(r"\[[^\]]+\]", pv.text)
# Not all themes will pad; if none found try a second theme
if not bracketed:
list_html2 = client.get("/themes/fragment/list?limit=5").text
ids = re.findall(r'data-theme-id=\"([^\"]+)\"', list_html2)
for tid in ids[1:]:
pv2 = client.get(f"/themes/fragment/preview/{tid}?limit=30")
if pv2.status_code == 200 and re.search(r"\[[^\]]+\]", pv2.text):
bracketed = ["ok"]
break
assert bracketed, "Expected at least one synthetic placeholder bracketed item in high-limit preview"
def test_preview_cache_hit_timing(monkeypatch, client):
# Warm first
list_html = client.get("/themes/fragment/list?limit=1").text
m = re.search(r'data-theme-id=\"([^\"]+)\"', list_html)
assert m, "Theme id missing"
theme_id = m.group(1)
# First build (miss)
r1 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert r1.status_code == 200
# Monkeypatch theme_preview._now to freeze time so second call counts as hit
import code.web.services.theme_preview as tp # type: ignore
orig_now = tp._now
monkeypatch.setattr(tp, "_now", lambda: orig_now())
r2 = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert r2.status_code == 200
# Deterministic service-level verification: second direct function call should short-circuit via cache
import code.web.services.theme_preview as tp # type: ignore
# Snapshot counters
pre_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0)
first_payload = tp.get_theme_preview(theme_id, limit=12)
second_payload = tp.get_theme_preview(theme_id, limit=12)
post_hits = getattr(tp, "_PREVIEW_CACHE_HITS", 0)
assert first_payload.get("sample"), "Missing sample items in preview"
# Cache hit should have incremented hits counter
assert post_hits >= pre_hits + 1 or post_hits > 0, "Expected cache hits counter to increase"
# Items list identity (names) should be identical even if build_ms differs (second call cached has no build_ms recompute)
first_names = [i.get("name") for i in first_payload.get("sample", [])]
second_names = [i.get("name") for i in second_payload.get("sample", [])]
assert first_names == second_names, "Item ordering changed between cached calls"
# Metrics cache hit counter is best-effort; do not hard fail if not exposed yet
metrics_resp = client.get("/themes/metrics")
if metrics_resp.status_code == 200:
metrics = metrics_resp.json()
# Soft assertion
if metrics.get("preview_cache_hits", 0) == 0:
pytest.skip("Preview cache hit not reflected in metrics (soft skip)")
def test_navigation_state_preservation_roundtrip(client):
# Simulate list fetch with search & filters appended
r = client.get("/themes/fragment/list?q=counters&limit=20&bucket=Common")
assert r.status_code == 200
# Extract a theme id then fetch detail fragment to simulate navigation
m = re.search(r'data-theme-id=\"([^\"]+)\"', r.text)
assert m, "Missing theme id in filtered list"
theme_id = m.group(1)
detail = client.get(f"/themes/fragment/detail/{theme_id}")
assert detail.status_code == 200
# Detail fragment should include theme display name or id in heading
assert theme_id in detail.text or "Theme Detail" in detail.text
# Ensure list fragment contained highlighted mark for query
assert "<mark>" in r.text, "Expected search term highlighting for state preservation"
# --- Mana cost parser parity (mirror of client JS simplified) ---
def _parse_mana_symbols(raw: str) -> List[str]:
# Emulate JS regex /\{([^}]+)\}/g
return re.findall(r"\{([^}]+)\}", raw or "")
@pytest.mark.parametrize(
"mana,expected_syms",
[
("{X}{2}{U}{B/P}", ["X", "2", "U", "B/P"]),
("{G/U}{G/U}{1}{G}", ["G/U", "G/U", "1", "G"]),
("{R}{R}{R}{R}{R}", ["R", "R", "R", "R", "R"]),
("{2/W}{2/W}{W}", ["2/W", "2/W", "W"]),
("{G}{G/P}{X}{C}", ["G", "G/P", "X", "C"]),
],
)
def test_mana_cost_parser_variants(mana, expected_syms):
assert _parse_mana_symbols(mana) == expected_syms
def test_lazy_load_img_attributes(client):
# Grab a preview and ensure loading="lazy" present on card images
list_html = client.get("/themes/fragment/list?limit=1").text
m = re.search(r'data-theme-id=\"([^\"]+)\"', list_html)
assert m
theme_id = m.group(1)
pv = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert pv.status_code == 200
# At least one img tag with loading="lazy" attribute
assert re.search(r"<img[^>]+loading=\"lazy\"", pv.text), "Expected lazy-loading images in preview"
def test_list_fragment_accessibility_tokens(client):
# Smoke test for role=listbox and row role=option presence (accessibility baseline)
r = client.get("/themes/fragment/list?limit=10")
assert r.status_code == 200
assert "role=\"option\"" in r.text
def test_accessibility_live_region_and_listbox(client):
r = client.get("/themes/fragment/list?limit=5")
assert r.status_code == 200
# List container should have role listbox and aria-live removed in fragment (fragment may omit outer wrapper) allow either present or absent gracefully
# We assert at least one aria-label attribute referencing themes count OR presence of pager text
assert ("aria-label=\"" in r.text) or ("Showing" in r.text)
def test_keyboard_nav_script_presence(client):
# Fetch full picker page (not just fragment) to inspect embedded JS for Arrow key handling
page = client.get("/themes/picker")
assert page.status_code == 200
body = page.text
assert "ArrowDown" in body and "ArrowUp" in body and "Enter" in body and "Escape" in body, "Keyboard nav handlers missing"
def test_list_fragment_filter_cache_fallback_timing(client):
# First call (likely cold) vs second call (cached by etag + filter cache)
import time as _t
t0 = _t.perf_counter()
client.get("/themes/fragment/list?limit=25&q=a")
first_ms = (_t.perf_counter() - t0) * 1000
t1 = _t.perf_counter()
client.get("/themes/fragment/list?limit=25&q=a")
second_ms = (_t.perf_counter() - t1) * 1000
# Soft assertion: second should not be dramatically slower; allow equality but fail if slower by >50%
if second_ms > first_ms * 1.5:
pytest.skip(f"Second call slower (cold path variance) first={first_ms:.1f}ms second={second_ms:.1f}ms")
def test_intersection_observer_lazy_fallback(client):
# Preview fragment should include script referencing IntersectionObserver (fallback path implied by try/catch) and images with loading lazy
list_html = client.get("/themes/fragment/list?limit=1").text
m = re.search(r'data-theme-id="([^"]+)"', list_html)
assert m
theme_id = m.group(1)
pv = client.get(f"/themes/fragment/preview/{theme_id}?limit=12")
assert pv.status_code == 200
html = pv.text
assert 'IntersectionObserver' in html or 'loading="lazy"' in html
assert re.search(r"<img[^>]+loading=\"lazy\"", html)
def test_session_storage_cache_script_tokens_present(client):
# Ensure list fragment contains cache_hit / cache_miss tokens for sessionStorage path instrumentation
frag = client.get("/themes/fragment/list?limit=5").text
assert 'cache_hit' in frag and 'cache_miss' in frag, "Expected cache_hit/cache_miss tokens in fragment script"

View file

@ -0,0 +1,62 @@
from __future__ import annotations
import os
import re
import importlib
import pytest
from fastapi.testclient import TestClient
def _new_client(prewarm: bool = False) -> TestClient:
# Ensure fresh import with desired env flags
if prewarm:
os.environ['WEB_THEME_FILTER_PREWARM'] = '1'
else:
os.environ.pop('WEB_THEME_FILTER_PREWARM', None)
# Remove existing module (if any) so lifespan runs again
if 'code.web.app' in list(importlib.sys.modules.keys()):
importlib.sys.modules.pop('code.web.app')
from code.web.app import app # type: ignore
return TestClient(app)
def _first_theme_id(client: TestClient) -> str:
html = client.get('/themes/fragment/list?limit=1').text
m = re.search(r'data-theme-id="([^"]+)"', html)
assert m, 'No theme id found'
return m.group(1)
def test_role_group_separators_and_role_chips():
client = _new_client()
theme_id = _first_theme_id(client)
pv_html = client.get(f'/themes/fragment/preview/{theme_id}?limit=18').text
# Ensure at least one role chip exists
assert 'role-chip' in pv_html, 'Expected role-chip elements in preview fragment'
# Capture group separator ordering
groups = re.findall(r'data-group="(examples|curated_synergy|payoff|enabler_support|wildcard)"', pv_html)
if groups:
# Remove duplicates preserving order
seen = []
for g in groups:
if g not in seen:
seen.append(g)
# Expected relative order subset prefix list
expected_order = ['examples', 'curated_synergy', 'payoff', 'enabler_support', 'wildcard']
# Filter expected list to those actually present and compare ordering
filtered_expected = [g for g in expected_order if g in seen]
assert seen == filtered_expected, f'Group separators out of order: {seen} vs expected subset {filtered_expected}'
def test_prewarm_flag_metrics():
client = _new_client(prewarm=True)
# Trigger at least one list request (though prewarm runs in lifespan already)
client.get('/themes/fragment/list?limit=5')
metrics_resp = client.get('/themes/metrics')
if metrics_resp.status_code != 200:
pytest.skip('Metrics endpoint unavailable')
metrics = metrics_resp.json()
# Soft assertion: if key missing, skip (older build)
if 'filter_prewarmed' not in metrics:
pytest.skip('filter_prewarmed metric not present')
assert metrics['filter_prewarmed'] in (True, 1), 'Expected filter_prewarmed to be True after prewarm'

View file

@ -0,0 +1,38 @@
from __future__ import annotations
import pytest
from code.web.services.theme_preview import get_theme_preview # type: ignore
from code.web.services.theme_catalog_loader import load_index, slugify, project_detail # type: ignore
@pytest.mark.parametrize("limit", [8, 12])
def test_preview_role_ordering(limit):
# Pick a deterministic existing theme (first catalog theme)
idx = load_index()
assert idx.catalog.themes, "No themes available for preview test"
theme = idx.catalog.themes[0].theme
preview = get_theme_preview(theme, limit=limit)
# Ensure curated examples (role=example) all come before any curated_synergy, which come before any payoff/enabler/support/wildcard
roles = [c["roles"][0] for c in preview["sample"] if c.get("roles")]
# Find first indices
first_curated_synergy = next((i for i, r in enumerate(roles) if r == "curated_synergy"), None)
first_non_curated = next((i for i, r in enumerate(roles) if r not in {"example", "curated_synergy"}), None)
# If both present, ordering constraints
if first_curated_synergy is not None and first_non_curated is not None:
assert first_curated_synergy < first_non_curated, "curated_synergy block should precede sampled roles"
# All example indices must be < any curated_synergy index
if first_curated_synergy is not None:
for i, r in enumerate(roles):
if r == "example":
assert i < first_curated_synergy, "example card found after curated_synergy block"
def test_synergy_commanders_no_overlap_with_examples():
idx = load_index()
theme_entry = idx.catalog.themes[0]
slug = slugify(theme_entry.theme)
detail = project_detail(slug, idx.slug_to_entry[slug], idx.slug_to_yaml, uncapped=False)
examples = set(detail.get("example_commanders") or [])
synergy_commanders = detail.get("synergy_commanders") or []
assert not (examples.intersection(synergy_commanders)), "synergy_commanders should not include example_commanders"

View file

@ -0,0 +1,72 @@
import os
import time
import json
from code.web.services.theme_preview import get_theme_preview, preview_metrics, bust_preview_cache # type: ignore
def test_colors_filter_constraint_green_subset():
"""colors=G should only return cards whose color identities are subset of {G} or colorless ('' list)."""
payload = get_theme_preview('Blink', limit=8, colors='G') # pick any theme; data-driven
for card in payload['sample']:
if not card['colors']:
continue
assert set(card['colors']).issubset({'G'}), f"Card {card['name']} had colors {card['colors']} outside filter"
def test_synthetic_placeholder_fill_present_when_short():
# Force scarcity via impossible color filter letter ensuring empty real pool -> synthetic placeholders
payload = get_theme_preview('Blink', limit=50, colors='Z')
# All real cards filtered out; placeholders must appear
synthetic_roles = [c for c in payload['sample'] if 'synthetic' in (c.get('roles') or [])]
assert synthetic_roles, 'Expected at least one synthetic placeholder entry under restrictive color filter'
assert any('synthetic_synergy_placeholder' in (c.get('reasons') or []) for c in synthetic_roles), 'Missing synthetic placeholder reason'
def test_cache_hit_timing_and_log(monkeypatch, capsys):
os.environ['WEB_THEME_PREVIEW_LOG'] = '1'
# Force fresh build
bust_preview_cache()
payload1 = get_theme_preview('Blink', limit=6)
assert payload1['cache_hit'] is False
# Second call should hit cache
payload2 = get_theme_preview('Blink', limit=6)
assert payload2['cache_hit'] is True
captured = capsys.readouterr().out.splitlines()
assert any('theme_preview_build' in line for line in captured), 'Missing build log'
assert any('theme_preview_cache_hit' in line for line in captured), 'Missing cache hit log'
def test_per_theme_percentiles_and_raw_counts():
bust_preview_cache()
for _ in range(5):
get_theme_preview('Blink', limit=6)
metrics = preview_metrics()
per = metrics['per_theme']
assert 'blink' in per, 'Expected theme slug in per_theme metrics'
blink_stats = per['blink']
assert 'p50_ms' in blink_stats and 'p95_ms' in blink_stats, 'Missing percentile metrics'
assert 'curated_total' in blink_stats and 'sampled_total' in blink_stats, 'Missing raw curated/sample per-theme totals'
def test_structured_log_contains_new_fields(capsys):
os.environ['WEB_THEME_PREVIEW_LOG'] = '1'
bust_preview_cache()
get_theme_preview('Blink', limit=5)
out_lines = capsys.readouterr().out.splitlines()
build_lines = [line for line in out_lines if 'theme_preview_build' in line]
assert build_lines, 'No build log lines found'
parsed = [json.loads(line) for line in build_lines]
obj = parsed[-1]
assert 'curated_total' in obj and 'sampled_total' in obj and 'role_counts' in obj, 'Missing expected structured log fields'
def test_warm_index_latency_reduction():
bust_preview_cache()
t0 = time.time()
get_theme_preview('Blink', limit=6)
cold = time.time() - t0
t1 = time.time()
get_theme_preview('Blink', limit=6)
warm = time.time() - t1
# Warm path should generally be faster; allow flakiness with generous factor
assert warm <= cold * 1.2, f"Expected warm path faster or near equal (cold={cold}, warm={warm})"

View file

@ -28,6 +28,7 @@ class ThemeEntry(BaseModel):
# Phase D editorial enhancements (optional)
example_commanders: List[str] = Field(default_factory=list, description="Curated example commanders illustrating the theme")
example_cards: List[str] = Field(default_factory=list, description="Representative non-commander cards (short, curated list)")
synergy_example_cards: List[str] = Field(default_factory=list, description="Optional curated synergy-relevant cards distinct from general example_cards")
synergy_commanders: List[str] = Field(default_factory=list, description="Commanders surfaced from top synergies (3/2/1 from top three synergies)")
deck_archetype: Optional[str] = Field(
None,
@ -113,6 +114,7 @@ class ThemeYAMLFile(BaseModel):
# Phase D optional editorial metadata (may be absent in existing YAMLs)
example_commanders: List[str] = Field(default_factory=list)
example_cards: List[str] = Field(default_factory=list)
synergy_example_cards: List[str] = Field(default_factory=list)
synergy_commanders: List[str] = Field(default_factory=list)
deck_archetype: Optional[str] = None
popularity_hint: Optional[str] = None # Free-form editorial note; bucket computed during merge

View file

@ -14,13 +14,41 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.gzip import GZipMiddleware
from typing import Any
from .services.combo_utils import detect_all as _detect_all
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
_TEMPLATES_DIR = _THIS_DIR / "templates"
_STATIC_DIR = _THIS_DIR / "static"
app = FastAPI(title="MTG Deckbuilder Web UI")
from contextlib import asynccontextmanager
@asynccontextmanager
async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
"""FastAPI lifespan context replacing deprecated on_event startup hooks.
Consolidates previous startup tasks:
- prewarm_common_filters (optional fast filter cache priming)
- theme preview card index warm (CSV parse avoidance for first preview)
Failures in warm tasks are intentionally swallowed to avoid blocking app start.
"""
# Prewarm theme filter cache (guarded internally by env flag)
try:
prewarm_common_filters()
except Exception:
pass
# Warm preview card index once
try: # local import to avoid cost if preview unused
from .services import theme_preview as _tp # type: ignore
_tp._maybe_build_card_index() # internal warm function
except Exception:
pass
yield # (no shutdown tasks currently)
app = FastAPI(title="MTG Deckbuilder Web UI", lifespan=_lifespan)
app.add_middleware(GZipMiddleware, minimum_size=500)
# Mount static if present
@ -64,6 +92,8 @@ def _compat_template_response(*args, **kwargs): # type: ignore[override]
templates.TemplateResponse = _compat_template_response # type: ignore[assignment]
# (Startup prewarm moved to lifespan handler _lifespan)
# Global template flags (env-driven)
def _as_bool(val: str | None, default: bool = False) -> bool:
if val is None:
@ -80,6 +110,7 @@ ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False)
ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), False)
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False) # initial snapshot (legacy)
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False)
THEME_PICKER_DIAGNOSTICS = _as_bool(os.getenv("WEB_THEME_PICKER_DIAGNOSTICS"), False)
def _as_int(val: str | None, default: int) -> int:
try:
return int(val) if val is not None and str(val).strip() != "" else default
@ -109,6 +140,7 @@ templates.env.globals.update({
"random_ui": RANDOM_UI,
"random_max_attempts": RANDOM_MAX_ATTEMPTS,
"random_timeout_ms": RANDOM_TIMEOUT_MS,
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
})
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
@ -552,6 +584,8 @@ try:
except Exception:
pass
## (Additional startup warmers consolidated into lifespan handler)
# --- Exception handling ---
def _wants_html(request: Request) -> bool:
try:

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field
class ThemeSummary(BaseModel):
id: str
theme: str
primary_color: Optional[str] = None
secondary_color: Optional[str] = None
popularity_bucket: Optional[str] = None
deck_archetype: Optional[str] = None
description: Optional[str] = None
synergies: List[str] = Field(default_factory=list)
synergy_count: int = 0
# Diagnostics-only fields (gated by flag)
has_fallback_description: Optional[bool] = None
editorial_quality: Optional[str] = None
class ThemeDetail(ThemeSummary):
curated_synergies: List[str] = Field(default_factory=list)
enforced_synergies: List[str] = Field(default_factory=list)
inferred_synergies: List[str] = Field(default_factory=list)
example_commanders: List[str] = Field(default_factory=list)
example_cards: List[str] = Field(default_factory=list)
synergy_commanders: List[str] = Field(default_factory=list)
# Diagnostics-only optional uncapped list
uncapped_synergies: Optional[List[str]] = None

View file

@ -5,13 +5,41 @@ from datetime import datetime as _dt
from pathlib import Path
from typing import Optional, Dict, Any
from fastapi import APIRouter
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import BackgroundTasks
from ..services.orchestrator import _ensure_setup_ready, _run_theme_metadata_enrichment # type: ignore
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from ..services.theme_catalog_loader import (
load_index,
project_detail,
slugify,
filter_slugs_fast,
summaries_for_slugs,
)
from ..services.theme_preview import get_theme_preview # type: ignore
from ..services.theme_catalog_loader import catalog_metrics, prewarm_common_filters # type: ignore
from ..services.theme_preview import preview_metrics # type: ignore
from ..services import theme_preview as _theme_preview_mod # type: ignore # for error counters
import os
from fastapi import Body
# In-memory client metrics & structured log counters (diagnostics only)
CLIENT_PERF: dict[str, list[float]] = {
"list_render_ms": [], # list_ready - list_render_start
"preview_load_ms": [], # optional future measure (not yet emitted)
}
LOG_COUNTS: dict[str, int] = {}
MAX_CLIENT_SAMPLES = 500 # cap to avoid unbounded growth
router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status
# Reuse the main app's template environment so nav globals stay consistent.
try: # circular-safe import: app defines templates before importing this router
from ..app import templates as _templates # type: ignore
except Exception: # Fallback (tests/minimal contexts)
_templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / 'templates'))
THEME_LIST_PATH = Path("config/themes/theme_list.json")
CATALOG_DIR = Path("config/themes/catalog")
STATUS_PATH = Path("csv_files/.setup_status.json")
@ -36,6 +64,57 @@ def _load_status() -> Dict[str, Any]:
return {}
def _load_fast_theme_list() -> Optional[list[dict[str, Any]]]:
"""Load precomputed lightweight theme list JSON if available.
Expected structure: {"themes": [{"id": str, "theme": str, "short_description": str, ...}, ...]}
Returns list or None on failure.
"""
try:
if THEME_LIST_PATH.exists():
raw = json.loads(THEME_LIST_PATH.read_text(encoding="utf-8") or "{}")
if isinstance(raw, dict):
arr = raw.get("themes")
if isinstance(arr, list):
# Shallow copy to avoid mutating original reference
# NOTE: Regression fix (2025-09-20): theme_list.json produced by current
# build pipeline does NOT include an explicit 'id' per theme (only 'theme').
# Earlier implementation required e.get('id') causing the fast path to
# treat the catalog as empty and show "No themes found." even though
# hundreds of themes exist. We now derive the id via slugify(theme) when
# missing, and also opportunistically compute a short_description snippet
# if absent (trim description to ~110 chars mirroring project_summary logic).
out: list[dict[str, Any]] = []
for e in arr:
if not isinstance(e, dict):
continue
theme_name = e.get("theme")
if not theme_name or not isinstance(theme_name, str):
continue
_id = e.get("id") or slugify(theme_name)
short_desc = e.get("short_description")
if not short_desc:
desc = e.get("description")
if isinstance(desc, str) and desc.strip():
sd = desc.strip()
if len(sd) > 110:
sd = sd[:107].rstrip() + ""
short_desc = sd
out.append({
"id": _id,
"theme": theme_name,
"short_description": short_desc,
})
# If we ended up with zero items (unexpected) fall back to None so caller
# will use full index logic instead of rendering empty state incorrectly.
if not out:
return None
return out
except Exception:
return None
return None
def _load_tag_flag_time() -> Optional[float]:
try:
if TAG_FLAG_PATH.exists():
@ -128,3 +207,672 @@ async def theme_refresh(background: BackgroundTasks):
return JSONResponse({"ok": True, "started": True})
except Exception as e: # pragma: no cover
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
# --- Phase E Theme Catalog APIs ---
def _diag_enabled() -> bool:
return (os.getenv("WEB_THEME_PICKER_DIAGNOSTICS") or "").strip().lower() in {"1", "true", "yes", "on"}
@router.get("/picker", response_class=HTMLResponse)
async def theme_picker_page(request: Request):
"""Render the theme picker shell.
Dynamic data (list, detail) loads via fragment endpoints. We still inject
known archetype list for the filter select so it is populated on initial load.
"""
archetypes: list[str] = []
try:
idx = load_index()
archetypes = sorted({t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}) # type: ignore[arg-type]
except Exception:
archetypes = []
return _templates.TemplateResponse(
"themes/picker.html",
{
"request": request,
"archetypes": archetypes,
"theme_picker_diagnostics": _diag_enabled(),
},
)
@router.get("/metrics")
async def theme_metrics():
if not _diag_enabled():
raise HTTPException(status_code=403, detail="diagnostics_disabled")
try:
idx = load_index()
prewarm_common_filters()
return JSONResponse({
"ok": True,
"etag": idx.etag,
"catalog": catalog_metrics(),
"preview": preview_metrics(),
"client_perf": {
"list_render_avg_ms": round(sum(CLIENT_PERF["list_render_ms"]) / len(CLIENT_PERF["list_render_ms"])) if CLIENT_PERF["list_render_ms"] else 0,
"list_render_count": len(CLIENT_PERF["list_render_ms"]),
"preview_load_avg_ms": round(sum(CLIENT_PERF["preview_load_ms"]) / len(CLIENT_PERF["preview_load_ms"])) if CLIENT_PERF["preview_load_ms"] else 0,
"preview_load_batch_count": len(CLIENT_PERF["preview_load_ms"]),
},
"log_counts": LOG_COUNTS,
})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.get("/", response_class=HTMLResponse)
async def theme_catalog_simple(request: Request):
"""Simplified catalog: list + search only (no per-row heavy data)."""
return _templates.TemplateResponse("themes/catalog_simple.html", {"request": request})
@router.get("/{theme_id}", response_class=HTMLResponse)
async def theme_catalog_detail_page(theme_id: str, request: Request):
"""Full detail page for a single theme (standalone route)."""
try:
idx = load_index()
except FileNotFoundError:
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
slug = slugify(theme_id)
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)
# Strip diagnostics-only fields for public page
detail.pop('has_fallback_description', None)
detail.pop('editorial_quality', None)
detail.pop('uncapped_synergies', None)
# Build example + synergy commanders (reuse logic from preview)
example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)]
synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)]
seen = set(example_commanders)
synergy_commanders: list[str] = []
for c in synergy_commanders_raw:
if c not in seen:
synergy_commanders.append(c)
seen.add(c)
# Render via reuse of detail fragment inside a page shell
return _templates.TemplateResponse(
"themes/detail_page.html",
{
"request": request,
"theme": detail,
"diagnostics": False,
"uncapped": False,
"yaml_available": False,
"example_commanders": example_commanders,
"synergy_commanders": synergy_commanders,
"standalone_page": True,
},
)
@router.get("/fragment/list", response_class=HTMLResponse)
async def theme_list_fragment(
request: Request,
q: str | None = None,
archetype: str | None = None,
bucket: str | None = None,
colors: str | None = None,
diagnostics: bool | None = None,
synergy_mode: str | None = Query(None, description="Synergy display mode: 'capped' (default) or 'full'"),
limit: int | None = Query(20, ge=1, le=100),
offset: int | None = Query(0, ge=0),
):
import time as _t
t0 = _t.time()
try:
idx = load_index()
except FileNotFoundError:
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
color_list = [c.strip() for c in colors.split(',')] if colors else None
# Fast filtering (falls back only for legacy logic differences if needed)
slugs = filter_slugs_fast(idx, q=q, archetype=archetype, bucket=bucket, colors=color_list)
diag = _diag_enabled() and bool(diagnostics)
lim = int(limit or 30)
off = int(offset or 0)
total = len(slugs)
slice_slugs = slugs[off: off + lim]
items = summaries_for_slugs(idx, slice_slugs)
# Synergy display logic: default 'capped' mode (cap at 6) unless diagnostics & user explicitly requests full
# synergy_mode can be 'full' to force uncapped in list (still diagnostics-gated to prevent layout spam in prod)
mode = (synergy_mode or '').strip().lower()
allow_full = (mode == 'full') and diag # only diagnostics may request full
SYNERGY_CAP = 6
if not allow_full:
for it in items:
syns = it.get("synergies") or []
if isinstance(syns, list) and len(syns) > SYNERGY_CAP:
it["synergies_capped"] = True
it["synergies_full"] = syns
it["synergies"] = syns[:SYNERGY_CAP]
if not diag:
for it in items:
it.pop('has_fallback_description', None)
it.pop('editorial_quality', None)
duration_ms = int(((_t.time() - t0) * 1000))
resp = _templates.TemplateResponse(
"themes/list_fragment.html",
{
"request": request,
"items": items,
"diagnostics": diag,
"total": total,
"limit": lim,
"offset": off,
"next_offset": off + lim if (off + lim) < total else None,
"prev_offset": off - lim if off - lim >= 0 else None,
},
)
resp.headers["X-ThemeCatalog-Filter-Duration-ms"] = str(duration_ms)
resp.headers["X-ThemeCatalog-Index-ETag"] = idx.etag
return resp
@router.get("/fragment/list_simple", response_class=HTMLResponse)
async def theme_list_simple_fragment(
request: Request,
q: 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.
"""
import time as _t
t0 = _t.time()
lim = int(limit or 100)
off = int(offset or 0)
fast_items = _load_fast_theme_list()
fast_used = False
items: list[dict[str, Any]] = []
total = 0
if fast_items is not None:
fast_used = True
# 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()]
total = len(fast_items)
slice_items = fast_items[off: off + lim]
for e in slice_items:
items.append({
"id": e.get("id"),
"theme": e.get("theme"),
"short_description": e.get("short_description"),
})
else:
# Fallback: load full index
try:
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)
total = len(slugs)
slice_slugs = slugs[off: off + lim]
items_raw = summaries_for_slugs(idx, slice_slugs)
for it in items_raw:
items.append({
"id": it.get("id"),
"theme": it.get("theme"),
"short_description": it.get("short_description"),
})
duration_ms = int(((_t.time() - t0) * 1000))
resp = _templates.TemplateResponse(
"themes/list_simple_fragment.html",
{
"request": request,
"items": items,
"total": total,
"limit": lim,
"offset": off,
"next_offset": off + lim if (off + lim) < total else None,
"prev_offset": off - lim if off - lim >= 0 else None,
},
)
resp.headers['X-ThemeCatalog-Simple-Duration-ms'] = str(duration_ms)
resp.headers['X-ThemeCatalog-Simple-Fast'] = '1' if fast_used else '0'
# Consistency: expose same filter duration style header used by full list fragment so
# tooling / DevTools inspection does not depend on which catalog view is active.
resp.headers['X-ThemeCatalog-Filter-Duration-ms'] = str(duration_ms)
return resp
@router.get("/fragment/detail/{theme_id}", response_class=HTMLResponse)
async def theme_detail_fragment(
theme_id: str,
diagnostics: bool | None = None,
uncapped: bool | None = None,
request: Request = None,
):
try:
idx = load_index()
except FileNotFoundError:
return HTMLResponse("<div class='error'>Catalog unavailable.</div>", status_code=503)
slug = slugify(theme_id)
entry = idx.slug_to_entry.get(slug)
if not entry:
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)
if not diag:
detail.pop('has_fallback_description', None)
detail.pop('editorial_quality', None)
detail.pop('uncapped_synergies', None)
return _templates.TemplateResponse(
"themes/detail_fragment.html",
{
"request": request,
"theme": detail,
"diagnostics": diag,
"uncapped": uncapped_enabled,
"yaml_available": diag, # gate by diagnostics flag
},
)
## (moved metrics route earlier to avoid collision with catch-all /{theme_id})
@router.get("/yaml/{theme_id}")
async def theme_yaml(theme_id: str):
"""Return raw YAML file for a theme (diagnostics/dev only)."""
if not _diag_enabled():
raise HTTPException(status_code=403, detail="diagnostics_disabled")
try:
idx = load_index()
except FileNotFoundError:
raise HTTPException(status_code=503, detail="catalog_unavailable")
slug = slugify(theme_id)
# Attempt to locate via slug -> YAML map, fallback path guess
y = idx.slug_to_yaml.get(slug)
if not y:
raise HTTPException(status_code=404, detail="yaml_not_found")
# Reconstruct minimal YAML (we have dict already)
import yaml as _yaml # local import to keep top-level lean
text = _yaml.safe_dump(y, sort_keys=False) # type: ignore
headers = {"Content-Type": "text/plain; charset=utf-8"}
return HTMLResponse(text, headers=headers)
@router.get("/api/themes")
async def api_themes(
request: Request,
q: str | None = Query(None, description="Substring filter on theme or synergies"),
archetype: str | None = Query(None, description="Filter by deck_archetype"),
bucket: str | None = Query(None, description="Filter by popularity bucket"),
colors: str | None = Query(None, description="Comma-separated color initials (e.g. G,W)"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
diagnostics: bool | None = Query(None, description="Force diagnostics mode (allowed only if flag enabled)"),
):
import time as _t
t0 = _t.time()
try:
idx = load_index()
except FileNotFoundError:
raise HTTPException(status_code=503, detail="catalog_unavailable")
color_list = [c.strip() for c in colors.split(",") if c.strip()] if colors else None
# Validate archetype quickly (fast path uses underlying entries anyway)
if archetype:
present_archetypes = {e.deck_archetype for e in idx.catalog.themes if e.deck_archetype}
if archetype not in present_archetypes:
slugs: list[str] = []
else:
slugs = filter_slugs_fast(idx, q=q, archetype=archetype, bucket=bucket, colors=color_list)
else:
slugs = filter_slugs_fast(idx, q=q, archetype=None, bucket=bucket, colors=color_list)
total = len(slugs)
slice_slugs = slugs[offset: offset + limit]
items = summaries_for_slugs(idx, slice_slugs)
diag = _diag_enabled() and bool(diagnostics)
if not diag:
# Strip diagnostics-only fields
for it in items:
# has_fallback_description is diagnostics-only
it.pop("has_fallback_description", None)
it.pop("editorial_quality", None)
duration_ms = int(((_t.time() - t0) * 1000))
headers = {
"ETag": idx.etag,
"Cache-Control": "no-cache", # Clients may still conditional GET using ETag
"X-ThemeCatalog-Filter-Duration-ms": str(duration_ms),
}
return JSONResponse({
"ok": True,
"count": total,
"items": items,
"next_offset": offset + limit if (offset + limit) < total else None,
"stale": False, # status already exposed elsewhere; keep placeholder for UI
"generated_at": idx.catalog.metadata_info.generated_at if idx.catalog.metadata_info else None,
"diagnostics": diag,
}, headers=headers)
@router.get("/api/search")
async def api_theme_search(
q: str = Query(..., min_length=1, description="Search query"),
limit: int = Query(15, ge=1, le=50),
include_synergies: bool = Query(False, description="Also match synergies (slower)"),
):
"""Lightweight search with tiered matching (exact > prefix > substring).
Performance safeguards:
- Stop scanning once we have >= limit and at least one exact/prefix.
- Substring phase limited to first 250 themes unless still under limit.
- Optional synergy search (off by default) to avoid wide fan-out of matches like 'aggro' in many synergy lists.
"""
try:
idx = load_index()
except FileNotFoundError:
return JSONResponse({"ok": False, "error": "catalog_unavailable"}, status_code=503)
qnorm = q.strip()
if not qnorm:
return JSONResponse({"ok": True, "items": []})
qlower = qnorm.lower()
exact: list[dict[str, Any]] = []
prefix: list[dict[str, Any]] = []
substr: list[dict[str, Any]] = []
seen: set[str] = set()
themes_iter = list(idx.catalog.themes) # type: ignore[attr-defined]
# Phase 1 + 2: exact / prefix
for t in themes_iter:
name = t.theme
slug = slugify(name)
lower_name = name.lower()
if lower_name == qlower or slug == qlower:
if slug not in seen:
exact.append({"id": slug, "theme": name})
seen.add(slug)
continue
if lower_name.startswith(qlower):
if slug not in seen:
prefix.append({"id": slug, "theme": name})
seen.add(slug)
if len(exact) + len(prefix) >= limit:
break
# Phase 3: substring (only if still room)
if (len(exact) + len(prefix)) < limit:
scan_limit = 250 # cap scan for responsiveness
for t in themes_iter[:scan_limit]:
name = t.theme
slug = slugify(name)
if slug in seen:
continue
if qlower in name.lower():
substr.append({"id": slug, "theme": name})
seen.add(slug)
if (len(exact) + len(prefix) + len(substr)) >= limit:
break
ordered = exact + prefix + substr
# Optional synergy search fill (lowest priority) if still space
if include_synergies and len(ordered) < limit:
remaining = limit - len(ordered)
for t in themes_iter:
if remaining <= 0:
break
slug = slugify(t.theme)
if slug in seen:
continue
syns = getattr(t, 'synergies', None) or []
try:
# Only a quick any() scan to keep it cheap
if any(qlower in s.lower() for s in syns):
ordered.append({"id": slug, "theme": t.theme})
seen.add(slug)
remaining -= 1
except Exception:
continue
if len(ordered) > limit:
ordered = ordered[:limit]
return JSONResponse({"ok": True, "items": ordered})
@router.get("/api/theme/{theme_id}")
async def api_theme_detail(
theme_id: str,
uncapped: bool | None = Query(False, description="Return uncapped synergy set (diagnostics mode only)"),
diagnostics: bool | None = Query(None, description="Diagnostics mode gating extra fields"),
):
try:
idx = load_index()
except FileNotFoundError:
raise HTTPException(status_code=503, detail="catalog_unavailable")
slug = slugify(theme_id)
entry = idx.slug_to_entry.get(slug)
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)
if not diag:
# Remove diagnostics-only fields
detail.pop("has_fallback_description", None)
detail.pop("editorial_quality", None)
detail.pop("uncapped_synergies", None)
headers = {"ETag": idx.etag, "Cache-Control": "no-cache"}
return JSONResponse({"ok": True, "theme": detail, "diagnostics": diag}, headers=headers)
@router.get("/api/theme/{theme_id}/preview")
async def api_theme_preview(
theme_id: str,
limit: int = Query(12, ge=1, le=30),
colors: str | None = Query(None, description="Comma separated color filter (currently placeholder)"),
commander: str | None = Query(None, description="Commander name to bias sampling (future)"),
):
try:
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
except KeyError:
raise HTTPException(status_code=404, detail="theme_not_found")
return JSONResponse({"ok": True, "preview": payload})
@router.get("/fragment/preview/{theme_id}", response_class=HTMLResponse)
async def theme_preview_fragment(
theme_id: str,
limit: int = Query(12, ge=1, le=30),
colors: str | None = None,
commander: str | None = None,
suppress_curated: bool = Query(False, description="If true, omit curated example cards/commanders from the sample area (used on detail page to avoid duplication)"),
minimal: bool = Query(False, description="Minimal inline variant (no header/controls/rationale used in detail page collapsible preview)"),
request: Request = None,
):
"""Return HTML fragment for theme preview with caching headers.
Adds ETag and Last-Modified headers (no strong caching enables conditional GET / 304).
ETag composed of catalog index etag + stable hash of preview payload (theme id + limit + commander).
"""
try:
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
except KeyError:
return HTMLResponse("<div class='error'>Theme not found.</div>", status_code=404)
# Load example commanders (authoritative list) from catalog detail for legality instead of inferring
example_commanders: list[str] = []
synergy_commanders: list[str] = []
try:
idx = load_index()
slug = slugify(theme_id)
entry = idx.slug_to_entry.get(slug)
if entry:
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=False)
example_commanders = [c for c in (detail.get("example_commanders") or []) if isinstance(c, str)]
synergy_commanders_raw = [c for c in (detail.get("synergy_commanders") or []) if isinstance(c, str)]
# De-duplicate any overlap with example commanders while preserving order
seen = set(example_commanders)
for c in synergy_commanders_raw:
if c not in seen:
synergy_commanders.append(c)
seen.add(c)
except Exception:
example_commanders = []
synergy_commanders = []
# Build ETag (use catalog etag + hash of core identifying fields to reflect underlying data drift)
import hashlib
import json as _json
import time as _time
try:
idx = load_index()
catalog_tag = idx.etag
except Exception:
catalog_tag = "unknown"
hash_src = _json.dumps({
"theme": theme_id,
"limit": limit,
"commander": commander,
"sample": payload.get("sample", [])[:3], # small slice for stability & speed
"v": 1,
}, sort_keys=True).encode("utf-8")
etag = "pv-" + hashlib.sha256(hash_src).hexdigest()[:20] + f"-{catalog_tag}"
# Conditional request support
if request is not None:
inm = request.headers.get("if-none-match")
if inm and inm == etag:
# 304 Not Modified FastAPI HTMLResponse with empty body & headers
resp = HTMLResponse(status_code=304, content="")
resp.headers["ETag"] = etag
from email.utils import formatdate as _fmtdate
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
resp.headers["Cache-Control"] = "no-cache"
return resp
ctx = {
"request": request,
"preview": payload,
"example_commanders": example_commanders,
"synergy_commanders": synergy_commanders,
"theme_id": theme_id,
"etag": etag,
"suppress_curated": suppress_curated,
"minimal": minimal,
}
resp = _templates.TemplateResponse("themes/preview_fragment.html", ctx)
resp.headers["ETag"] = etag
from email.utils import formatdate as _fmtdate
resp.headers["Last-Modified"] = _fmtdate(timeval=_time.time(), usegmt=True)
resp.headers["Cache-Control"] = "no-cache"
return resp
# --- Preview Export Endpoints (CSV / JSON) ---
@router.get("/preview/{theme_id}/export.json")
async def export_preview_json(
theme_id: str,
limit: int = Query(12, ge=1, le=60),
colors: str | None = None,
commander: str | None = None,
curated_only: bool | None = Query(False, description="If true, only curated example + curated synergy entries returned"),
):
try:
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
except KeyError:
raise HTTPException(status_code=404, detail="theme_not_found")
items = payload.get("sample", [])
if curated_only:
items = [i for i in items if any(r in {"example", "curated_synergy", "synthetic"} for r in (i.get("roles") or []))]
return JSONResponse({
"ok": True,
"theme": payload.get("theme"),
"theme_id": payload.get("theme_id"),
"curated_only": bool(curated_only),
"generated_at": payload.get("generated_at"),
"limit": limit,
"count": len(items),
"items": items,
})
@router.get("/preview/{theme_id}/export.csv")
async def export_preview_csv(
theme_id: str,
limit: int = Query(12, ge=1, le=60),
colors: str | None = None,
commander: str | None = None,
curated_only: bool | None = Query(False, description="If true, only curated example + curated synergy entries returned"),
):
import csv as _csv
import io as _io
try:
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
except KeyError:
raise HTTPException(status_code=404, detail="theme_not_found")
rows = payload.get("sample", [])
if curated_only:
rows = [r for r in rows if any(role in {"example", "curated_synergy", "synthetic"} for role in (r.get("roles") or []))]
buf = _io.StringIO()
fieldnames = ["name", "roles", "score", "rarity", "mana_cost", "color_identity_list", "pip_colors", "reasons", "tags"]
w = _csv.DictWriter(buf, fieldnames=fieldnames)
w.writeheader()
for r in rows:
w.writerow({
"name": r.get("name"),
"roles": ";".join(r.get("roles") or []),
"score": r.get("score"),
"rarity": r.get("rarity"),
"mana_cost": r.get("mana_cost"),
"color_identity_list": ";".join(r.get("color_identity_list") or []),
"pip_colors": ";".join(r.get("pip_colors") or []),
"reasons": ";".join(r.get("reasons") or []),
"tags": ";".join(r.get("tags") or []),
})
csv_text = buf.getvalue()
from fastapi.responses import Response
filename = f"preview_{theme_id}.csv"
headers = {
"Content-Disposition": f"attachment; filename={filename}",
"Content-Type": "text/csv; charset=utf-8",
}
return Response(content=csv_text, media_type="text/csv", headers=headers)
# --- New: Client performance marks ingestion (Section E) ---
@router.post("/metrics/client")
async def ingest_client_metrics(request: Request, payload: dict[str, Any] = Body(...)):
if not _diag_enabled():
raise HTTPException(status_code=403, detail="diagnostics_disabled")
try:
events = payload.get("events")
if not isinstance(events, list):
return JSONResponse({"ok": False, "error": "invalid_events"}, status_code=400)
for ev in events:
if not isinstance(ev, dict):
continue
name = ev.get("name")
dur = ev.get("duration_ms")
if name == "list_render" and isinstance(dur, (int, float)) and dur >= 0:
CLIENT_PERF["list_render_ms"].append(float(dur))
if len(CLIENT_PERF["list_render_ms"]) > MAX_CLIENT_SAMPLES:
# Drop oldest half to keep memory bounded
CLIENT_PERF["list_render_ms"] = CLIENT_PERF["list_render_ms"][len(CLIENT_PERF["list_render_ms"])//2:]
elif name == "preview_load_batch":
# Aggregate average into samples list (store avg redundantly for now)
avg_ms = ev.get("avg_ms")
if isinstance(avg_ms, (int, float)) and avg_ms >= 0:
CLIENT_PERF["preview_load_ms"].append(float(avg_ms))
if len(CLIENT_PERF["preview_load_ms"]) > MAX_CLIENT_SAMPLES:
CLIENT_PERF["preview_load_ms"] = CLIENT_PERF["preview_load_ms"][len(CLIENT_PERF["preview_load_ms"])//2:]
return JSONResponse({"ok": True, "ingested": len(events)})
except Exception as e: # pragma: no cover
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
# --- New: Structured logging ingestion for cache/prefetch events (Section E) ---
@router.post("/log")
async def ingest_structured_log(request: Request, payload: dict[str, Any] = Body(...)):
if not _diag_enabled():
raise HTTPException(status_code=403, detail="diagnostics_disabled")
try:
event = payload.get("event")
if not isinstance(event, str) or not event:
return JSONResponse({"ok": False, "error": "missing_event"}, status_code=400)
LOG_COUNTS[event] = LOG_COUNTS.get(event, 0) + 1
if event == "preview_fetch_error": # client-side fetch failure
try:
_theme_preview_mod._PREVIEW_REQUEST_ERROR_COUNT += 1 # type: ignore[attr-defined]
except Exception:
pass
# Lightweight echo back
return JSONResponse({"ok": True, "count": LOG_COUNTS[event]})
except Exception as e: # pragma: no cover
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

View file

@ -910,6 +910,18 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
_run_theme_metadata_enrichment(out_func)
except Exception:
pass
# Bust theme-related in-memory caches so new catalog reflects immediately
try:
from .theme_catalog_loader import bust_filter_cache # type: ignore
from .theme_preview import bust_preview_cache # type: ignore
bust_filter_cache("catalog_refresh")
bust_preview_cache("catalog_refresh")
try:
out_func("[cache] Busted theme filter & preview caches after catalog refresh")
except Exception:
pass
except Exception:
pass
except Exception as _e: # pragma: no cover - non-critical diagnostics only
try:
out_func(f"Theme catalog refresh failed: {_e}")
@ -1092,6 +1104,13 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
duration_s = None
# Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress
_refresh_theme_catalog(out, force=True, fast_path=False)
try:
from .theme_catalog_loader import bust_filter_cache # type: ignore
from .theme_preview import bust_preview_cache # type: ignore
bust_filter_cache("tagging_complete")
bust_preview_cache("tagging_complete")
except Exception:
pass
payload = {"running": False, "phase": "done", "message": "Setup complete", "color": None, "percent": 100, "finished_at": finished, "themes_exported": True}
if duration_s is not None:
payload["duration_seconds"] = duration_s

View file

@ -0,0 +1,511 @@
"""Theme catalog loader & projection utilities.
Phase E foundation + Phase F performance optimizations.
Responsibilities:
- Lazy load & cache merged catalog JSON + YAML overlays.
- Provide slug -> ThemeEntry and raw YAML maps.
- 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.
"""
from __future__ import annotations
from pathlib import Path
import json
import re
from typing import Dict, Any, List, Optional, Tuple, Iterable
import yaml # type: ignore
from pydantic import BaseModel
# Import ThemeCatalog & ThemeEntry with resilient fallbacks.
# Runtime contexts:
# - Local dev (cwd == project root): modules available as top-level.
# - Docker (WORKDIR /app/code): modules also available top-level.
# - Package/zip installs (rare): may require 'code.' prefix.
try:
from type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore
except ImportError: # pragma: no cover - fallback path
try:
from code.type_definitions_theme_catalog import ThemeCatalog, ThemeEntry # type: ignore
except ImportError: # pragma: no cover - last resort (avoid beyond top-level relative import)
raise
CATALOG_JSON = Path("config/themes/theme_list.json")
YAML_DIR = Path("config/themes/catalog")
_CACHE: Dict[str, Any] = {}
# Filter result cache: key = (etag, q, archetype, bucket, colors_tuple)
_FILTER_CACHE: Dict[Tuple[str, Optional[str], Optional[str], Optional[str], Optional[Tuple[str, ...]]], List[str]] = {}
_FILTER_REQUESTS = 0
_FILTER_CACHE_HITS = 0
_FILTER_LAST_BUST_AT: float | None = None
_FILTER_PREWARMED = False # guarded single-run prewarm flag
# --- Performance: YAML newest mtime scan caching ---
# Repeated calls to _needs_reload() previously scanned every *.yml file (~700 files)
# on each theme list/filter request, contributing noticeable latency on Windows (many stat calls).
# We cache the newest YAML mtime for a short interval (default 2s, tunable via env) to avoid
# excessive directory traversal while still detecting edits quickly during active authoring.
_YAML_SCAN_CACHE: Dict[str, Any] = { # keys: newest_mtime (float), scanned_at (float)
"newest_mtime": 0.0,
"scanned_at": 0.0,
}
try:
import os as _os
_YAML_SCAN_INTERVAL = float((_os.getenv("THEME_CATALOG_YAML_SCAN_INTERVAL_SEC") or "2.0"))
except Exception: # pragma: no cover - fallback
_YAML_SCAN_INTERVAL = 2.0
class SlugThemeIndex(BaseModel):
catalog: ThemeCatalog
slug_to_entry: Dict[str, ThemeEntry]
slug_to_yaml: Dict[str, Dict[str, Any]] # raw YAML data per theme
# Performance precomputations for fast list filtering
summary_by_slug: Dict[str, Dict[str, Any]]
haystack_by_slug: Dict[str, str]
primary_color_by_slug: Dict[str, Optional[str]]
secondary_color_by_slug: Dict[str, Optional[str]]
mtime: float
yaml_mtime_max: float
etag: str
_GENERIC_DESCRIPTION_PREFIXES = [
"Accumulates ", # many auto-generated variants start like this
"Builds around ",
"Leverages ",
]
_SLUG_RE_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def slugify(name: str) -> str:
s = name.lower().strip()
# Preserve +1/+1 pattern meaningfully by converting '+' to 'plus'
s = s.replace("+", "plus")
s = _SLUG_RE_NON_ALNUM.sub("-", s)
s = re.sub(r"-+", "-", s).strip("-")
return s
def _needs_reload() -> bool:
if not CATALOG_JSON.exists():
return bool(_CACHE)
mtime = CATALOG_JSON.stat().st_mtime
idx: SlugThemeIndex | None = _CACHE.get("index") # type: ignore
if idx is None:
return True
if mtime > idx.mtime:
return True
# If any YAML newer than catalog mtime or newest YAML newer than cached scan -> reload
if YAML_DIR.exists():
import time as _t
now = _t.time()
# Use cached newest mtime if within interval; else rescan.
if (now - _YAML_SCAN_CACHE["scanned_at"]) < _YAML_SCAN_INTERVAL:
newest_yaml = _YAML_SCAN_CACHE["newest_mtime"]
else:
# Fast path: use os.scandir for lower overhead vs Path.glob
newest = 0.0
try:
import os as _os
with _os.scandir(YAML_DIR) as it: # type: ignore[arg-type]
for entry in it:
if entry.is_file() and entry.name.endswith('.yml'):
try:
st = entry.stat()
if st.st_mtime > newest:
newest = st.st_mtime
except Exception:
continue
except Exception: # pragma: no cover - scandir failure fallback
newest = max((p.stat().st_mtime for p in YAML_DIR.glob('*.yml')), default=0.0)
_YAML_SCAN_CACHE["newest_mtime"] = newest
_YAML_SCAN_CACHE["scanned_at"] = now
newest_yaml = newest
if newest_yaml > idx.yaml_mtime_max:
return True
return False
def _load_yaml_map() -> Tuple[Dict[str, Dict[str, Any]], float]:
latest = 0.0
out: Dict[str, Dict[str, Any]] = {}
if not YAML_DIR.exists():
return out, latest
for p in YAML_DIR.glob("*.yml"):
try:
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if isinstance(data, dict):
slug = data.get("id") or slugify(data.get("display_name", p.stem))
out[str(slug)] = data
if p.stat().st_mtime > latest:
latest = p.stat().st_mtime
except Exception:
continue
return out, latest
def _compute_etag(size: int, mtime: float, yaml_mtime: float) -> str:
return f"{int(size)}-{int(mtime)}-{int(yaml_mtime)}"
def load_index() -> SlugThemeIndex:
if not _needs_reload():
return _CACHE["index"] # type: ignore
if not CATALOG_JSON.exists():
raise FileNotFoundError("theme_list.json missing")
raw = json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}")
catalog = ThemeCatalog.model_validate(raw)
slug_to_entry: Dict[str, ThemeEntry] = {}
summary_by_slug: Dict[str, Dict[str, Any]] = {}
haystack_by_slug: Dict[str, str] = {}
primary_color_by_slug: Dict[str, Optional[str]] = {}
secondary_color_by_slug: Dict[str, Optional[str]] = {}
for t in catalog.themes:
slug = slugify(t.theme)
slug_to_entry[slug] = t
summary = project_summary(t)
summary_by_slug[slug] = summary
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
yaml_map, yaml_mtime_max = _load_yaml_map()
idx = SlugThemeIndex(
catalog=catalog,
slug_to_entry=slug_to_entry,
slug_to_yaml=yaml_map,
summary_by_slug=summary_by_slug,
haystack_by_slug=haystack_by_slug,
primary_color_by_slug=primary_color_by_slug,
secondary_color_by_slug=secondary_color_by_slug,
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),
)
_CACHE["index"] = idx
_FILTER_CACHE.clear() # Invalidate fast filter cache on any reload
return idx
def validate_catalog_integrity(rebuild: bool = True) -> Dict[str, Any]:
"""Validate that theme_list.json matches current YAML set via catalog_hash.
Returns dict with status fields. If drift detected and rebuild=True and
THEME_CATALOG_MODE merge script is available, attempts an automatic rebuild.
Environment flags:
THEME_CATALOG_VALIDATE=1 enables invocation from app startup (else caller controls).
"""
out: Dict[str, Any] = {"ok": True, "rebuild_attempted": False, "drift": False}
if not CATALOG_JSON.exists():
out.update({"ok": False, "error": "theme_list_missing"})
return out
try:
raw = json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}")
meta = raw.get("metadata_info") or {}
recorded_hash = meta.get("catalog_hash")
except Exception as e: # pragma: no cover
out.update({"ok": False, "error": f"read_error:{e}"})
return out
# Recompute hash using same heuristic as build script
from scripts.build_theme_catalog import load_catalog_yaml # type: ignore
try:
yaml_catalog = load_catalog_yaml(verbose=False) # keyed by display_name
except Exception:
yaml_catalog = {}
import hashlib as _hashlib
h = _hashlib.sha256()
for name in sorted(yaml_catalog.keys()):
yobj = yaml_catalog[name]
try:
payload = (
getattr(yobj, 'id', ''),
getattr(yobj, 'display_name', ''),
tuple(getattr(yobj, 'curated_synergies', []) or []),
tuple(getattr(yobj, 'enforced_synergies', []) or []),
tuple(getattr(yobj, 'example_commanders', []) or []),
tuple(getattr(yobj, 'example_cards', []) or []),
getattr(yobj, 'deck_archetype', None),
getattr(yobj, 'popularity_hint', None),
getattr(yobj, 'description', None),
getattr(yobj, 'editorial_quality', None),
)
h.update(repr(payload).encode('utf-8'))
except Exception:
continue
# Synergy cap influences ordering; include if present in meta
if meta.get('synergy_cap') is not None:
h.update(str(meta.get('synergy_cap')).encode('utf-8'))
current_hash = h.hexdigest()
if recorded_hash and recorded_hash != current_hash:
out['drift'] = True
out['recorded_hash'] = recorded_hash
out['current_hash'] = current_hash
if rebuild:
import subprocess
import os as _os
import sys as _sys
out['rebuild_attempted'] = True
try:
env = {**_os.environ, 'THEME_CATALOG_MODE': 'merge'}
subprocess.run([
_sys.executable, 'code/scripts/build_theme_catalog.py'
], check=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out['rebuild_ok'] = True
except Exception as e:
out['rebuild_ok'] = False
out['rebuild_error'] = str(e)
else:
out['drift'] = False
out['recorded_hash'] = recorded_hash
out['current_hash'] = current_hash
return out
def has_fallback_description(entry: ThemeEntry) -> bool:
if not entry.description:
return True
desc = entry.description.strip()
# Simple heuristic: generic if starts with any generic prefix and length < 160
if len(desc) < 160 and any(desc.startswith(p) for p in _GENERIC_DESCRIPTION_PREFIXES):
return True
return False
def project_summary(entry: ThemeEntry) -> Dict[str, Any]:
# Short description (snippet) for list hover / condensed display
desc = entry.description or ""
short_desc = desc.strip()
if len(short_desc) > 110:
short_desc = short_desc[:107].rstrip() + ""
return {
"id": slugify(entry.theme),
"theme": entry.theme,
"primary_color": entry.primary_color,
"secondary_color": entry.secondary_color,
"popularity_bucket": entry.popularity_bucket,
"deck_archetype": entry.deck_archetype,
"editorial_quality": entry.editorial_quality,
"description": entry.description,
"short_description": short_desc,
"synergies": entry.synergies,
"synergy_count": len(entry.synergies),
"has_fallback_description": has_fallback_description(entry),
}
def _split_synergies(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, Any]]) -> Dict[str, List[str]]:
y = yaml_map.get(slug)
if not y:
return {"curated": [], "enforced": [], "inferred": []}
return {
"curated": [s for s in y.get("curated_synergies", []) if isinstance(s, str)],
"enforced": [s for s in y.get("enforced_synergies", []) if isinstance(s, str)],
"inferred": [s for s in y.get("inferred_synergies", []) if isinstance(s, str)],
}
def project_detail(slug: str, entry: ThemeEntry, yaml_map: Dict[str, Dict[str, Any]], uncapped: bool = False) -> Dict[str, Any]:
seg = _split_synergies(slug, entry, yaml_map)
uncapped_synergies: Optional[List[str]] = None
if uncapped:
# Full ordered list reconstructed: curated + enforced (preserve duplication guard) + inferred
seen = set()
full: List[str] = []
for block in (seg["curated"], seg["enforced"], seg["inferred"]):
for s in block:
if s not in seen:
full.append(s)
seen.add(s)
uncapped_synergies = full
d = project_summary(entry)
d.update({
"curated_synergies": seg["curated"],
"enforced_synergies": seg["enforced"],
"inferred_synergies": seg["inferred"],
})
if uncapped_synergies is not None:
d["uncapped_synergies"] = uncapped_synergies
# Add editorial lists with YAML fallback (REGRESSION FIX 2025-09-20):
# The current theme_list.json emitted by the build pipeline omits the
# example_* and synergy_* editorial arrays. Earlier logic populated these
# from the JSON so previews showed curated examples. After the omission,
# ThemeEntry fields default to empty lists and curated examples vanished
# from the preview (user-reported). We now fallback to the per-theme YAML
# source when the ThemeEntry lists are empty to restore expected behavior
# without requiring an immediate catalog rebuild.
y_entry: Dict[str, Any] = yaml_map.get(slug, {}) or {}
def _norm_list(val: Any) -> List[str]:
if isinstance(val, list):
return [str(x) for x in val if isinstance(x, str)]
return []
example_commanders = entry.example_commanders or _norm_list(y_entry.get("example_commanders"))
example_cards = entry.example_cards or _norm_list(y_entry.get("example_cards"))
synergy_example_cards = getattr(entry, 'synergy_example_cards', None) or _norm_list(y_entry.get("synergy_example_cards"))
synergy_commanders = entry.synergy_commanders or _norm_list(y_entry.get("synergy_commanders"))
# YAML fallback for description & selected editorial fields (REGRESSION FIX 2025-09-20):
# theme_list.json currently omits description/editorial_quality/popularity_bucket for some themes after P2 build changes.
# Use YAML values when the ThemeEntry field is empty/None. Preserve existing non-empty entry values.
description = entry.description or y_entry.get("description") or None
editorial_quality = entry.editorial_quality or y_entry.get("editorial_quality") or None
popularity_bucket = entry.popularity_bucket or y_entry.get("popularity_bucket") or None
d.update({
"example_commanders": example_commanders,
"example_cards": example_cards,
"synergy_example_cards": synergy_example_cards,
"synergy_commanders": synergy_commanders,
"description": description,
"editorial_quality": editorial_quality,
"popularity_bucket": popularity_bucket,
})
return d
def filter_entries(entries: List[ThemeEntry], *, q: Optional[str] = None, archetype: Optional[str] = None, bucket: Optional[str] = None, colors: Optional[List[str]] = None) -> List[ThemeEntry]:
q_lower = q.lower() if q else None
colors_set = {c.strip().upper() for c in colors} if colors else None
out: List[ThemeEntry] = []
for e in entries:
if archetype and e.deck_archetype != archetype:
continue
if bucket and e.popularity_bucket != bucket:
continue
if colors_set:
pc = (e.primary_color or "").upper()[:1]
sc = (e.secondary_color or "").upper()[:1]
if not (pc in colors_set or sc in colors_set):
continue
if q_lower:
hay = "|".join([e.theme] + e.synergies).lower()
if q_lower not in hay:
continue
out.append(e)
return out
# -------------------- Optimized filtering (fast path) --------------------
def _color_match(slug: str, colors_set: Optional[set[str]], idx: SlugThemeIndex) -> bool:
if not colors_set:
return True
pc = (idx.primary_color_by_slug.get(slug) or "").upper()[:1]
sc = (idx.secondary_color_by_slug.get(slug) or "").upper()[:1]
return (pc in colors_set) or (sc in colors_set)
def filter_slugs_fast(
idx: SlugThemeIndex,
*,
q: Optional[str] = None,
archetype: Optional[str] = None,
bucket: Optional[str] = None,
colors: Optional[List[str]] = None,
) -> List[str]:
"""Return filtered slugs using precomputed haystacks & memoized cache.
Cache key: (etag, q_lower, archetype, bucket, colors_tuple) where colors_tuple
is sorted & uppercased. Cache invalidates automatically when index reloads.
"""
colors_key: Optional[Tuple[str, ...]] = (
tuple(sorted({c.strip().upper() for c in colors})) if colors else None
)
cache_key = (idx.etag, q.lower() if q else None, archetype, bucket, colors_key)
global _FILTER_REQUESTS, _FILTER_CACHE_HITS
_FILTER_REQUESTS += 1
cached = _FILTER_CACHE.get(cache_key)
if cached is not None:
_FILTER_CACHE_HITS += 1
return cached
q_lower = q.lower() if q else None
colors_set = set(colors_key) if colors_key else None
out: List[str] = []
for slug, entry in idx.slug_to_entry.items():
if archetype and entry.deck_archetype != archetype:
continue
if bucket and entry.popularity_bucket != bucket:
continue
if colors_set and not _color_match(slug, colors_set, idx):
continue
if q_lower and q_lower not in idx.haystack_by_slug.get(slug, ""):
continue
out.append(slug)
_FILTER_CACHE[cache_key] = out
return out
def summaries_for_slugs(idx: SlugThemeIndex, slugs: Iterable[str]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for s in slugs:
summ = idx.summary_by_slug.get(s)
if summ:
out.append(summ.copy()) # shallow copy so route can pop diag-only fields
return out
def catalog_metrics() -> Dict[str, Any]:
"""Return lightweight catalog filtering/cache metrics (diagnostics only)."""
return {
"filter_requests": _FILTER_REQUESTS,
"filter_cache_hits": _FILTER_CACHE_HITS,
"filter_cache_entries": len(_FILTER_CACHE),
"filter_last_bust_at": _FILTER_LAST_BUST_AT,
"filter_prewarmed": _FILTER_PREWARMED,
}
def bust_filter_cache(reason: str | None = None) -> None:
"""Clear fast filter cache (call after catalog rebuild or yaml change)."""
global _FILTER_CACHE, _FILTER_LAST_BUST_AT
try:
_FILTER_CACHE.clear()
import time as _t
_FILTER_LAST_BUST_AT = _t.time()
except Exception:
pass
def prewarm_common_filters(max_archetypes: int = 12) -> None:
"""Pre-execute a handful of common filter queries to prime the fast cache.
This is intentionally conservative (only a small cartesian of bucket/archetype)
and gated by WEB_THEME_FILTER_PREWARM=1 environment variable as well as a
single-run guard. Safe to call multiple times (no-op after first success).
"""
global _FILTER_PREWARMED
if _FILTER_PREWARMED:
return
import os
if (os.getenv("WEB_THEME_FILTER_PREWARM") or "").strip().lower() not in {"1", "true", "yes", "on"}:
return
try:
idx = load_index()
except Exception:
return
# Gather archetypes & buckets (limited)
archetypes: List[str] = []
try:
archetypes = [a for a in {t.deck_archetype for t in idx.catalog.themes if t.deck_archetype}][:max_archetypes] # type: ignore[arg-type]
except Exception:
archetypes = []
buckets = ["Very Common", "Common", "Uncommon", "Niche", "Rare"]
# Execute fast filter queries (ignore output, we only want cache side effects)
try:
# Global (no filters) & each bucket
filter_slugs_fast(idx)
for b in buckets:
filter_slugs_fast(idx, bucket=b)
# Archetype only combos (first N)
for a in archetypes:
filter_slugs_fast(idx, archetype=a)
# Archetype + bucket cross (cap combinations)
for a in archetypes[:5]:
for b in buckets[:3]:
filter_slugs_fast(idx, archetype=a, bucket=b)
_FILTER_PREWARMED = True
except Exception:
# Swallow any unexpected error; prewarm is opportunistic
return

View file

@ -0,0 +1,862 @@
"""Theme preview sampling (Phase F enhanced sampling & diversity heuristics).
Summary of implemented capabilities and pending roadmap items documented inline.
"""
from __future__ import annotations
from pathlib import Path
import csv
import time
import random
from collections import OrderedDict, deque
from typing import List, Dict, Any, Optional, Tuple, Iterable
import os
import json
import threading
try:
import yaml # type: ignore
except Exception: # pragma: no cover - PyYAML already in requirements; defensive
yaml = None # type: ignore
from .theme_catalog_loader import load_index, slugify, project_detail
# NOTE: Remainder of module keeps large logic blocks; imports consolidated above per PEP8.
# Commander bias configuration constants
COMMANDER_COLOR_FILTER_STRICT = True # If commander found, restrict sample to its color identity (except colorless)
COMMANDER_OVERLAP_BONUS = 1.8 # additive score bonus for sharing at least one tag with commander
COMMANDER_THEME_MATCH_BONUS = 0.9 # extra if also matches theme directly
## (duplicate imports removed)
# Adaptive TTL configuration (can be toggled via THEME_PREVIEW_ADAPTIVE=1)
# Starts at a baseline and is adjusted up/down based on cache hit ratio bands.
TTL_SECONDS = 600 # current effective TTL (mutable)
_TTL_BASE = 600
_TTL_MIN = 300
_TTL_MAX = 900
_ADAPT_SAMPLE_WINDOW = 120 # number of recent requests to evaluate
_ADAPTATION_ENABLED = (os.getenv("THEME_PREVIEW_ADAPTIVE") or "").lower() in {"1","true","yes","on"}
_RECENT_HITS: deque[bool] = deque(maxlen=_ADAPT_SAMPLE_WINDOW)
_LAST_ADAPT_AT: float | None = None
_ADAPT_INTERVAL_S = 30 # do not adapt more often than every 30s
_BG_REFRESH_THREAD_STARTED = False
_BG_REFRESH_INTERVAL_S = int(os.getenv("THEME_PREVIEW_BG_REFRESH_INTERVAL") or 120)
_BG_REFRESH_ENABLED = (os.getenv("THEME_PREVIEW_BG_REFRESH") or "").lower() in {"1","true","yes","on"}
# Adaptive background refresh heuristics (P2): we will adjust per-loop sleep based on
# recent error rate & p95 build latency. Bounds: [30s, 5 * base interval].
_BG_REFRESH_MIN = 30
_BG_REFRESH_MAX = max(300, _BG_REFRESH_INTERVAL_S * 5)
# Per-theme error histogram (P2 observability)
_PREVIEW_PER_THEME_ERRORS: Dict[str, int] = {}
# Optional curated synergy pair matrix externalization (P2 DATA).
_CURATED_SYNERGY_MATRIX_PATH = Path("config/themes/curated_synergy_matrix.yml")
_CURATED_SYNERGY_MATRIX: Dict[str, Dict[str, Any]] | None = None
def _load_curated_synergy_matrix() -> None:
global _CURATED_SYNERGY_MATRIX
if _CURATED_SYNERGY_MATRIX is not None:
return
if not _CURATED_SYNERGY_MATRIX_PATH.exists() or yaml is None:
_CURATED_SYNERGY_MATRIX = None
return
try:
with _CURATED_SYNERGY_MATRIX_PATH.open('r', encoding='utf-8') as fh:
data = yaml.safe_load(fh) or {}
if isinstance(data, dict):
# Expect top-level key 'pairs' but allow raw mapping
pairs = data.get('pairs', data)
if isinstance(pairs, dict):
_CURATED_SYNERGY_MATRIX = pairs # type: ignore
else:
_CURATED_SYNERGY_MATRIX = None
else:
_CURATED_SYNERGY_MATRIX = None
except Exception:
_CURATED_SYNERGY_MATRIX = None
_load_curated_synergy_matrix()
def _maybe_adapt_ttl(now: float) -> None:
"""Adjust global TTL_SECONDS based on recent hit ratio bands.
Strategy:
- If hit ratio < 0.25: decrease TTL slightly (favor freshness) ( -60s )
- If hit ratio between 0.250.55: gently nudge toward base ( +/- 30s toward _TTL_BASE )
- If hit ratio between 0.550.75: slight increase (+60s) (stability payoff)
- If hit ratio > 0.75: stronger increase (+90s) to leverage locality
Never exceeds [_TTL_MIN, _TTL_MAX]. Only runs if enough samples.
"""
global TTL_SECONDS, _LAST_ADAPT_AT
if not _ADAPTATION_ENABLED:
return
if len(_RECENT_HITS) < max(30, int(_ADAPT_SAMPLE_WINDOW * 0.5)):
return # insufficient data
if _LAST_ADAPT_AT and (now - _LAST_ADAPT_AT) < _ADAPT_INTERVAL_S:
return
hit_ratio = sum(1 for h in _RECENT_HITS if h) / len(_RECENT_HITS)
new_ttl = TTL_SECONDS
if hit_ratio < 0.25:
new_ttl = max(_TTL_MIN, TTL_SECONDS - 60)
elif hit_ratio < 0.55:
# move 30s toward base
if TTL_SECONDS > _TTL_BASE:
new_ttl = max(_TTL_BASE, TTL_SECONDS - 30)
elif TTL_SECONDS < _TTL_BASE:
new_ttl = min(_TTL_BASE, TTL_SECONDS + 30)
elif hit_ratio < 0.75:
new_ttl = min(_TTL_MAX, TTL_SECONDS + 60)
else:
new_ttl = min(_TTL_MAX, TTL_SECONDS + 90)
if new_ttl != TTL_SECONDS:
TTL_SECONDS = new_ttl
try:
print(json.dumps({"event":"theme_preview_ttl_adapt","hit_ratio":round(hit_ratio,3),"ttl":TTL_SECONDS})) # noqa: T201
except Exception:
pass
_LAST_ADAPT_AT = now
def _compute_bg_interval() -> int:
"""Derive adaptive sleep interval using recent metrics (P2 PERF)."""
try:
m = preview_metrics()
p95 = float(m.get('preview_p95_build_ms') or 0.0)
err_rate = float(m.get('preview_error_rate_pct') or 0.0)
base = _BG_REFRESH_INTERVAL_S
# Heuristic: high latency -> lengthen interval slightly (avoid stampede), high error rate -> shorten (refresh quicker)
interval = base
if p95 > 350: # slow builds
interval = int(base * 1.75)
elif p95 > 250:
interval = int(base * 1.4)
elif p95 < 120:
interval = int(base * 0.85)
# Error rate influence
if err_rate > 5.0:
interval = max(_BG_REFRESH_MIN, int(interval * 0.6))
elif err_rate < 1.0 and p95 < 180:
# Very healthy -> stretch slightly (less churn)
interval = min(_BG_REFRESH_MAX, int(interval * 1.15))
return max(_BG_REFRESH_MIN, min(_BG_REFRESH_MAX, interval))
except Exception:
return max(_BG_REFRESH_MIN, _BG_REFRESH_INTERVAL_S)
def _bg_refresh_loop(): # pragma: no cover (background behavior)
import time as _t
while True:
if not _BG_REFRESH_ENABLED:
return
try:
ranked = sorted(_PREVIEW_PER_THEME_REQUESTS.items(), key=lambda kv: kv[1], reverse=True)
top = [slug for slug,_cnt in ranked[:10]]
for slug in top:
try:
get_theme_preview(slug, limit=12, colors=None, commander=None, uncapped=True)
except Exception:
continue
except Exception:
pass
_t.sleep(_compute_bg_interval())
def _ensure_bg_refresh_thread(): # pragma: no cover
global _BG_REFRESH_THREAD_STARTED
if _BG_REFRESH_THREAD_STARTED or not _BG_REFRESH_ENABLED:
return
try:
th = threading.Thread(target=_bg_refresh_loop, name="theme_preview_bg_refresh", daemon=True)
th.start()
_BG_REFRESH_THREAD_STARTED = True
except Exception:
pass
_PREVIEW_CACHE: "OrderedDict[Tuple[str, int, str | None, str | None, str], Dict[str, Any]]" = OrderedDict()
_CARD_INDEX: Dict[str, List[Dict[str, Any]]] = {}
_CARD_INDEX_MTIME: float | None = None
_PREVIEW_REQUESTS = 0
_PREVIEW_CACHE_HITS = 0
_PREVIEW_ERROR_COUNT = 0 # rolling count of preview build failures (non-cache operational)
_PREVIEW_REQUEST_ERROR_COUNT = 0 # client side reported fetch errors
_PREVIEW_BUILD_MS_TOTAL = 0.0
_PREVIEW_BUILD_COUNT = 0
_PREVIEW_LAST_BUST_AT: float | None = None
# Per-theme stats and global distribution tracking
_PREVIEW_PER_THEME: Dict[str, Dict[str, Any]] = {}
_PREVIEW_PER_THEME_REQUESTS: Dict[str, int] = {}
_BUILD_DURATIONS = deque(maxlen=500) # rolling window for percentile calc
_ROLE_GLOBAL_COUNTS: Dict[str, int] = {"payoff": 0, "enabler": 0, "support": 0, "wildcard": 0}
_CURATED_GLOBAL = 0 # example + curated_synergy (non-synthetic curated content)
_SAMPLED_GLOBAL = 0
# Rarity normalization mapping (baseline extend as new variants appear)
_RARITY_NORM = {
"mythic rare": "mythic",
"mythic": "mythic",
"m": "mythic",
"rare": "rare",
"r": "rare",
"uncommon": "uncommon",
"u": "uncommon",
"common": "common",
"c": "common",
}
def _normalize_rarity(raw: str) -> str:
r = (raw or "").strip().lower()
return _RARITY_NORM.get(r, r)
def _preview_cache_max() -> int:
try:
val_raw = (__import__('os').getenv('THEME_PREVIEW_CACHE_MAX') or '400')
val = int(val_raw)
if val <= 0:
raise ValueError("cache max must be >0")
return val
except Exception:
# Emit single-line warning (stdout) diagnostics style (won't break)
try:
print(json.dumps({"event":"theme_preview_cache_config_warning","message":"Invalid THEME_PREVIEW_CACHE_MAX; using default 400"})) # noqa: T201
except Exception:
pass
return 400
def _enforce_cache_limit():
try:
limit = max(50, _preview_cache_max())
while len(_PREVIEW_CACHE) > limit:
_PREVIEW_CACHE.popitem(last=False) # FIFO eviction
except Exception:
pass
CARD_FILES_GLOB = [
Path("csv_files/blue_cards.csv"),
Path("csv_files/white_cards.csv"),
Path("csv_files/black_cards.csv"),
Path("csv_files/red_cards.csv"),
Path("csv_files/green_cards.csv"),
Path("csv_files/colorless_cards.csv"),
Path("csv_files/cards.csv"), # fallback large file last
]
THEME_TAGS_COL = "themeTags"
NAME_COL = "name"
COLOR_IDENTITY_COL = "colorIdentity"
MANA_COST_COL = "manaCost"
RARITY_COL = "rarity" # Some CSVs may not include; optional
def _maybe_build_card_index():
global _CARD_INDEX, _CARD_INDEX_MTIME
latest = 0.0
mtimes: List[float] = []
for p in CARD_FILES_GLOB:
if p.exists():
mt = p.stat().st_mtime
mtimes.append(mt)
if mt > latest:
latest = mt
if _CARD_INDEX and _CARD_INDEX_MTIME and latest <= _CARD_INDEX_MTIME:
return
# Rebuild index
_CARD_INDEX = {}
for p in CARD_FILES_GLOB:
if not p.exists():
continue
try:
with p.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
if not reader.fieldnames or THEME_TAGS_COL not in reader.fieldnames:
continue
for row in reader:
name = row.get(NAME_COL) or row.get("faceName") or ""
tags_raw = row.get(THEME_TAGS_COL) or ""
# tags stored like "['Blink', 'Enter the Battlefield']"; naive parse
tags = [t.strip(" '[]") for t in tags_raw.split(',') if t.strip()] if tags_raw else []
if not tags:
continue
color_id = (row.get(COLOR_IDENTITY_COL) or "").strip()
mana_cost = (row.get(MANA_COST_COL) or "").strip()
rarity = _normalize_rarity(row.get(RARITY_COL) or "")
for tg in tags:
if not tg:
continue
_CARD_INDEX.setdefault(tg, []).append({
"name": name,
"color_identity": color_id,
"tags": tags,
"mana_cost": mana_cost,
"rarity": rarity,
# Pre-parsed helpers (color identity list & pip colors from mana cost)
"color_identity_list": list(color_id) if color_id else [],
"pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}],
})
except Exception:
continue
_CARD_INDEX_MTIME = latest
def _classify_role(theme: str, synergies: List[str], tags: List[str]) -> str:
tag_set = set(tags)
synergy_overlap = tag_set.intersection(synergies)
if theme in tag_set:
return "payoff"
if len(synergy_overlap) >= 2:
return "enabler"
if len(synergy_overlap) == 1:
return "support"
return "wildcard"
def _seed_from(theme: str, commander: Optional[str]) -> int:
base = f"{theme.lower()}|{(commander or '').lower()}".encode("utf-8")
# simple deterministic hash (stable across runs within Python version keep primitive)
h = 0
for b in base:
h = (h * 131 + b) & 0xFFFFFFFF
return h or 1
def _deterministic_shuffle(items: List[Any], seed: int) -> None:
rnd = random.Random(seed)
rnd.shuffle(items)
def _score_card(theme: str, synergies: List[str], role: str, tags: List[str]) -> float:
tag_set = set(tags)
synergy_overlap = len(tag_set.intersection(synergies))
score = 0.0
if theme in tag_set:
score += 3.0
score += synergy_overlap * 1.2
# Role weight baseline
role_weights = {
"payoff": 2.5,
"enabler": 2.0,
"support": 1.5,
"wildcard": 0.9,
}
score += role_weights.get(role, 0.5)
# Base rarity weighting (future: dynamic diminishing duplicate penalty)
# Access rarity via closure later by augmenting item after score (handled outside)
return score
def _commander_overlap_scale(commander_tags: set[str], card_tags: List[str], synergy_set: set[str]) -> float:
"""Refined overlap scaling: only synergy tag intersections count toward diminishing curve.
Uses geometric diminishing returns: bonus = B * (1 - 0.5 ** n) where n is synergy overlap count.
Guarantees first overlap grants 50% of base, second 75%, third 87.5%, asymptotically approaching B.
"""
if not commander_tags or not synergy_set:
return 0.0
overlap_synergy = len(commander_tags.intersection(synergy_set).intersection(card_tags))
if overlap_synergy <= 0:
return 0.0
return COMMANDER_OVERLAP_BONUS * (1 - (0.5 ** overlap_synergy))
def _lookup_commander(commander: Optional[str]) -> Optional[Dict[str, Any]]:
if not commander:
return None
_maybe_build_card_index()
# Commander can appear under many tags; brute scan limited to first match
needle = commander.lower().strip()
for tag_cards in _CARD_INDEX.values():
for c in tag_cards:
if c.get("name", "").lower() == needle:
return c
return None
def _sample_real_cards_for_theme(theme: str, limit: int, colors_filter: Optional[str], *, synergies: List[str], commander: Optional[str]) -> List[Dict[str, Any]]:
_maybe_build_card_index()
pool = _CARD_INDEX.get(theme) or []
if not pool:
return []
commander_card = _lookup_commander(commander)
commander_colors: set[str] = set(commander_card.get("color_identity", "")) if commander_card else set()
commander_tags: set[str] = set(commander_card.get("tags", [])) if commander_card else set()
if colors_filter:
allowed = {c.strip().upper() for c in colors_filter.split(',') if c.strip()}
if allowed:
pool = [c for c in pool if set(c.get("color_identity", "")).issubset(allowed) or not c.get("color_identity")]
# Apply commander color identity restriction if configured
if commander_card and COMMANDER_COLOR_FILTER_STRICT and commander_colors:
# Allow single off-color splash for 4-5 color commanders (leniency policy) with later mild penalty
allow_splash = len(commander_colors) >= 4
new_pool = []
for c in pool:
ci = set(c.get("color_identity", ""))
if not ci or ci.issubset(commander_colors):
new_pool.append(c)
continue
if allow_splash:
off = ci - commander_colors
if len(off) == 1: # single off-color splash
# mark for later penalty (avoid mutating shared index structure deeply; tag ephemeral flag)
c["_splash_off_color"] = True # type: ignore
new_pool.append(c)
continue
pool = new_pool
# Build role buckets
seen_names: set[str] = set()
payoff: List[Dict[str, Any]] = []
enabler: List[Dict[str, Any]] = []
support: List[Dict[str, Any]] = []
wildcard: List[Dict[str, Any]] = []
rarity_counts: Dict[str, int] = {}
synergy_set = set(synergies)
# Rarity calibration (P2 SAMPLING): allow tuning via env; default adjusted after observation.
rarity_weight_base = {
"mythic": float(os.getenv("RARITY_W_MYTHIC", "1.2")),
"rare": float(os.getenv("RARITY_W_RARE", "0.9")),
"uncommon": float(os.getenv("RARITY_W_UNCOMMON", "0.65")),
"common": float(os.getenv("RARITY_W_COMMON", "0.4")),
}
for raw in pool:
nm = raw.get("name")
if not nm or nm in seen_names:
continue
seen_names.add(nm)
tags = raw.get("tags", [])
role = _classify_role(theme, synergies, tags)
score = _score_card(theme, synergies, role, tags)
reasons = [f"role:{role}", f"synergy_overlap:{len(set(tags).intersection(synergies))}"]
if commander_card:
if theme in tags:
score += COMMANDER_THEME_MATCH_BONUS
reasons.append("commander_theme_match")
scaled = _commander_overlap_scale(commander_tags, tags, synergy_set)
if scaled:
score += scaled
reasons.append(f"commander_synergy_overlap:{len(commander_tags.intersection(synergy_set).intersection(tags))}:{round(scaled,2)}")
reasons.append("commander_bias")
rarity = raw.get("rarity") or ""
if rarity:
base_rarity_weight = rarity_weight_base.get(rarity, 0.25)
count_so_far = rarity_counts.get(rarity, 0)
# Diminishing influence: divide by (1 + 0.4 * duplicates_already)
score += base_rarity_weight / (1 + 0.4 * count_so_far)
rarity_counts[rarity] = count_so_far + 1
reasons.append(f"rarity_weight_calibrated:{rarity}:{round(base_rarity_weight/(1+0.4*count_so_far),2)}")
# Splash leniency penalty (applied after other scoring)
if raw.get("_splash_off_color"):
score -= 0.3
reasons.append("splash_off_color_penalty:-0.3")
item = {
"name": nm,
"colors": list(raw.get("color_identity", "")),
"roles": [role],
"tags": tags,
"score": score,
"reasons": reasons,
"mana_cost": raw.get("mana_cost"),
"rarity": rarity,
# Newly exposed server authoritative parsed helpers
"color_identity_list": raw.get("color_identity_list", []),
"pip_colors": raw.get("pip_colors", []),
}
if role == "payoff":
payoff.append(item)
elif role == "enabler":
enabler.append(item)
elif role == "support":
support.append(item)
else:
wildcard.append(item)
# Deterministic shuffle inside each bucket to avoid bias from CSV ordering
seed = _seed_from(theme, commander)
for bucket in (payoff, enabler, support, wildcard):
_deterministic_shuffle(bucket, seed)
# stable secondary ordering: higher score first, then name
bucket.sort(key=lambda x: (-x["score"], x["name"]))
# Diversity targets (after curated examples are pinned externally)
target_payoff = max(1, int(round(limit * 0.4)))
target_enabler_support = max(1, int(round(limit * 0.4)))
# support grouped with enabler for quota distribution
target_wild = max(0, limit - target_payoff - target_enabler_support)
def take(n: int, source: List[Dict[str, Any]]) -> Iterable[Dict[str, Any]]:
for i in range(min(n, len(source))):
yield source[i]
chosen: List[Dict[str, Any]] = []
# Collect payoff
chosen.extend(take(target_payoff, payoff))
# Collect enabler + support mix
remaining_for_enab = target_enabler_support
es_combined = enabler + support
chosen.extend(take(remaining_for_enab, es_combined))
# Collect wildcards
chosen.extend(take(target_wild, wildcard))
# If still short fill from remaining (payoff first, then enab, support, wildcard)
if len(chosen) < limit:
def fill_from(src: List[Dict[str, Any]]):
nonlocal chosen
for it in src:
if len(chosen) >= limit:
break
if it not in chosen:
chosen.append(it)
for bucket in (payoff, enabler, support, wildcard):
fill_from(bucket)
# Role saturation penalty (post-selection adjustment): discourage dominance overflow beyond soft thresholds
role_soft_caps = {
"payoff": int(round(limit * 0.5)),
"enabler": int(round(limit * 0.35)),
"support": int(round(limit * 0.35)),
"wildcard": int(round(limit * 0.25)),
}
role_seen: Dict[str, int] = {k: 0 for k in role_soft_caps}
for it in chosen:
r = (it.get("roles") or [None])[0]
if not r or r not in role_soft_caps:
continue
role_seen[r] += 1
if role_seen[r] > max(1, role_soft_caps[r]):
it["score"] = it.get("score", 0) - 0.4
(it.setdefault("reasons", [])).append("role_saturation_penalty:-0.4")
# Truncate and re-rank final sequence deterministically by score then name (already ordered by selection except fill)
if len(chosen) > limit:
chosen = chosen[:limit]
# Normalize score scale (optional future; keep raw for now)
return chosen
# key: (slug, limit, colors, commander, etag)
def _now() -> float: # small indirection for future test monkeypatch
return time.time()
def _build_stub_items(detail: Dict[str, Any], limit: int, colors_filter: Optional[str], *, commander: Optional[str]) -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
# Start with curated example cards if present, else generic example_cards
curated_cards = detail.get("example_cards") or []
for idx, name in enumerate(curated_cards):
if len(items) >= limit:
break
items.append({
"name": name,
"colors": [], # unknown without deeper card DB link
"roles": ["example"],
"tags": [],
"score": float(limit - idx), # simple descending score
"reasons": ["curated_example"],
})
# Curated synergy example cards (if any) follow standard examples but before sampled
synergy_curated = detail.get("synergy_example_cards") or []
for name in synergy_curated:
if len(items) >= limit:
break
# Skip duplicates with example_cards
if any(it["name"] == name for it in items):
continue
items.append({
"name": name,
"colors": [],
"roles": ["curated_synergy"],
"tags": [],
"score": max((it["score"] for it in items), default=1.0) - 0.1, # just below top examples
"reasons": ["curated_synergy_example"],
})
# Remaining slots after curated examples
remaining = max(0, limit - len(items))
if remaining:
theme_name = detail.get("theme")
if isinstance(theme_name, str):
all_synergies = []
# Use uncapped synergies if available else merged list
if detail.get("uncapped_synergies"):
all_synergies = detail.get("uncapped_synergies") or []
else:
# Combine curated/enforced/inferred
seen = set()
for blk in (detail.get("curated_synergies") or [], detail.get("enforced_synergies") or [], detail.get("inferred_synergies") or []):
for s in blk:
if s not in seen:
all_synergies.append(s)
seen.add(s)
real_cards = _sample_real_cards_for_theme(theme_name, remaining, colors_filter, synergies=all_synergies, commander=commander)
for rc in real_cards:
if len(items) >= limit:
break
items.append(rc)
if len(items) < limit:
# Pad using synergies as synthetic placeholders to reach requested size
synergies = detail.get("uncapped_synergies") or detail.get("synergies") or []
for s in synergies:
if len(items) >= limit:
break
synthetic_name = f"[{s}]"
items.append({
"name": synthetic_name,
"colors": [],
"roles": ["synthetic"],
"tags": [s],
"score": 0.5, # lower score to keep curated first
"reasons": ["synthetic_synergy_placeholder"],
})
return items
def get_theme_preview(theme_id: str, *, limit: int = 12, colors: Optional[str] = None, commander: Optional[str] = None, uncapped: bool = True) -> Dict[str, Any]:
global _PREVIEW_REQUESTS, _PREVIEW_CACHE_HITS, _PREVIEW_BUILD_MS_TOTAL, _PREVIEW_BUILD_COUNT
idx = load_index()
slug = slugify(theme_id)
entry = idx.slug_to_entry.get(slug)
if not entry:
raise KeyError("theme_not_found")
# Use uncapped synergies for better placeholder coverage (diagnostics flag gating not applied here; placeholder only)
detail = project_detail(slug, entry, idx.slug_to_yaml, uncapped=uncapped)
colors_key = colors or None
commander_key = commander or None
cache_key = (slug, limit, colors_key, commander_key, idx.etag)
_PREVIEW_REQUESTS += 1
cached = _PREVIEW_CACHE.get(cache_key)
if cached and (_now() - cached["_cached_at"]) < TTL_SECONDS:
_PREVIEW_CACHE_HITS += 1
_RECENT_HITS.append(True)
# Count request (even if cache hit) for per-theme metrics
_PREVIEW_PER_THEME_REQUESTS[slug] = _PREVIEW_PER_THEME_REQUESTS.get(slug, 0) + 1
# Structured cache hit log (diagnostics gated)
try:
if (os.getenv("WEB_THEME_PREVIEW_LOG") or "").lower() in {"1","true","yes","on"}:
print(json.dumps({
"event": "theme_preview_cache_hit",
"theme": slug,
"limit": limit,
"colors": colors_key,
"commander": commander_key,
"ttl_remaining_s": round(TTL_SECONDS - (_now() - cached["_cached_at"]), 2)
}, separators=(",",":"))) # noqa: T201
except Exception:
pass
# Annotate cache hit flag (shallow copy to avoid mutating stored payload timings)
payload_cached = dict(cached["payload"])
payload_cached["cache_hit"] = True
return payload_cached
_RECENT_HITS.append(False)
# Build items
t0 = _now()
try:
items = _build_stub_items(detail, limit, colors_key, commander=commander_key)
except Exception as e:
# Record error histogram & propagate
_PREVIEW_PER_THEME_ERRORS[slug] = _PREVIEW_PER_THEME_ERRORS.get(slug, 0) + 1
_PREVIEW_ERROR_COUNT += 1 # type: ignore
raise e
# Race condition guard (P2 RESILIENCE): If we somehow produced an empty sample (e.g., catalog rebuild mid-flight)
# retry a limited number of times with small backoff.
if not items:
for _retry in range(2): # up to 2 retries
time.sleep(0.05)
try:
items = _build_stub_items(detail, limit, colors_key, commander=commander_key)
except Exception:
_PREVIEW_PER_THEME_ERRORS[slug] = _PREVIEW_PER_THEME_ERRORS.get(slug, 0) + 1
_PREVIEW_ERROR_COUNT += 1 # type: ignore
break
if items:
try:
print(json.dumps({"event":"theme_preview_retry_after_empty","theme":slug})) # noqa: T201
except Exception:
pass
break
build_ms = (_now() - t0) * 1000.0
_PREVIEW_BUILD_MS_TOTAL += build_ms
_PREVIEW_BUILD_COUNT += 1
# Duplicate suppression safety across roles (should already be unique, defensive)
seen_names: set[str] = set()
dedup: List[Dict[str, Any]] = []
for it in items:
nm = it.get("name")
if not nm:
continue
if nm in seen_names:
continue
seen_names.add(nm)
dedup.append(it)
items = dedup
# Aggregate statistics
curated_count = sum(1 for i in items if any(r in {"example", "curated_synergy"} for r in (i.get("roles") or [])))
sampled_core_roles = {"payoff", "enabler", "support", "wildcard"}
role_counts_local: Dict[str, int] = {r: 0 for r in sampled_core_roles}
for i in items:
roles = i.get("roles") or []
for r in roles:
if r in role_counts_local:
role_counts_local[r] += 1
# Update global counters
global _ROLE_GLOBAL_COUNTS, _CURATED_GLOBAL, _SAMPLED_GLOBAL
for r, c in role_counts_local.items():
_ROLE_GLOBAL_COUNTS[r] = _ROLE_GLOBAL_COUNTS.get(r, 0) + c
_CURATED_GLOBAL += curated_count
_SAMPLED_GLOBAL += sum(role_counts_local.values())
_BUILD_DURATIONS.append(build_ms)
per = _PREVIEW_PER_THEME.setdefault(slug, {"builds": 0, "total_ms": 0.0, "durations": deque(maxlen=50), "role_counts": {r: 0 for r in sampled_core_roles}, "curated": 0, "sampled": 0})
per["builds"] += 1
per["total_ms"] += build_ms
per["durations"].append(build_ms)
per["curated"] += curated_count
per["sampled"] += sum(role_counts_local.values())
for r, c in role_counts_local.items():
per["role_counts"][r] = per["role_counts"].get(r, 0) + c
synergies_used = detail.get("uncapped_synergies") or detail.get("synergies") or []
payload = {
"theme_id": slug,
"theme": detail.get("theme"),
"count_total": len(items), # population size TBD when full sampling added
"sample": items,
"synergies_used": synergies_used,
"generated_at": idx.catalog.metadata_info.generated_at if idx.catalog.metadata_info else None,
"colors_filter": colors_key,
"commander": commander_key,
"stub": False if any(it.get("roles") and it["roles"][0] in {"payoff", "support", "enabler", "wildcard"} for it in items) else True,
"role_counts": role_counts_local,
"curated_pct": round((curated_count / max(1, len(items))) * 100, 2),
"build_ms": round(build_ms, 2),
"curated_total": curated_count,
"sampled_total": sum(role_counts_local.values()),
"cache_hit": False,
}
_PREVIEW_CACHE[cache_key] = {"payload": payload, "_cached_at": _now()}
_PREVIEW_CACHE.move_to_end(cache_key)
_enforce_cache_limit()
# Track request count post-build
_PREVIEW_PER_THEME_REQUESTS[slug] = _PREVIEW_PER_THEME_REQUESTS.get(slug, 0) + 1
# Structured logging (opt-in)
try:
if (os.getenv("WEB_THEME_PREVIEW_LOG") or "").lower() in {"1","true","yes","on"}:
log_obj = {
"event": "theme_preview_build",
"theme": slug,
"limit": limit,
"colors": colors_key,
"commander": commander_key,
"build_ms": round(build_ms, 2),
"curated_pct": payload["curated_pct"],
"curated_total": payload["curated_total"],
"sampled_total": payload["sampled_total"],
"role_counts": role_counts_local,
"cache_hit": False,
}
print(json.dumps(log_obj, separators=(",",":"))) # noqa: T201
except Exception:
pass
# Post-build adaptive TTL evaluation & background refresher initialization
_maybe_adapt_ttl(_now())
_ensure_bg_refresh_thread()
return payload
def _percentile(sorted_vals: List[float], pct: float) -> float:
if not sorted_vals:
return 0.0
k = (len(sorted_vals) - 1) * pct
f = int(k)
c = min(f + 1, len(sorted_vals) - 1)
if f == c:
return sorted_vals[f]
d0 = sorted_vals[f] * (c - k)
d1 = sorted_vals[c] * (k - f)
return d0 + d1
def preview_metrics() -> Dict[str, Any]:
avg_ms = (_PREVIEW_BUILD_MS_TOTAL / _PREVIEW_BUILD_COUNT) if _PREVIEW_BUILD_COUNT else 0.0
durations_list = sorted(list(_BUILD_DURATIONS))
p95 = _percentile(durations_list, 0.95)
# Role distribution actual vs target (aggregate)
total_roles = sum(_ROLE_GLOBAL_COUNTS.values()) or 1
target = {"payoff": 0.4, "enabler+support": 0.4, "wildcard": 0.2}
actual_enabler_support = (_ROLE_GLOBAL_COUNTS.get("enabler", 0) + _ROLE_GLOBAL_COUNTS.get("support", 0)) / total_roles
role_distribution = {
"payoff": {
"count": _ROLE_GLOBAL_COUNTS.get("payoff", 0),
"actual_pct": round((_ROLE_GLOBAL_COUNTS.get("payoff", 0) / total_roles) * 100, 2),
"target_pct": target["payoff"] * 100,
},
"enabler_support": {
"count": _ROLE_GLOBAL_COUNTS.get("enabler", 0) + _ROLE_GLOBAL_COUNTS.get("support", 0),
"actual_pct": round(actual_enabler_support * 100, 2),
"target_pct": target["enabler+support"] * 100,
},
"wildcard": {
"count": _ROLE_GLOBAL_COUNTS.get("wildcard", 0),
"actual_pct": round((_ROLE_GLOBAL_COUNTS.get("wildcard", 0) / total_roles) * 100, 2),
"target_pct": target["wildcard"] * 100,
},
}
editorial_coverage_pct = round((_CURATED_GLOBAL / max(1, (_CURATED_GLOBAL + _SAMPLED_GLOBAL))) * 100, 2)
per_theme_stats = {}
for slug, data in list(_PREVIEW_PER_THEME.items())[:50]:
durs = list(data.get("durations", []))
sd = sorted(durs)
p50 = _percentile(sd, 0.50)
p95_local = _percentile(sd, 0.95)
per_theme_stats[slug] = {
"avg_ms": round(data["total_ms"] / max(1, data["builds"]), 2),
"p50_ms": round(p50, 2),
"p95_ms": round(p95_local, 2),
"builds": data["builds"],
"avg_curated_pct": round((data["curated"] / max(1, (data["curated"] + data["sampled"])) ) * 100, 2),
"requests": _PREVIEW_PER_THEME_REQUESTS.get(slug, 0),
"curated_total": data.get("curated", 0),
"sampled_total": data.get("sampled", 0),
}
error_rate = 0.0
total_req = _PREVIEW_REQUESTS or 0
if total_req:
error_rate = round((_PREVIEW_ERROR_COUNT / total_req) * 100, 2)
# Example coverage enforcement flag: when curated coverage exceeds threshold (default 90%)
try:
enforce_threshold = float(os.getenv("EXAMPLE_ENFORCE_THRESHOLD", "90"))
except Exception:
enforce_threshold = 90.0
example_enforcement_active = editorial_coverage_pct >= enforce_threshold
return {
"preview_requests": _PREVIEW_REQUESTS,
"preview_cache_hits": _PREVIEW_CACHE_HITS,
"preview_cache_entries": len(_PREVIEW_CACHE),
"preview_avg_build_ms": round(avg_ms, 2),
"preview_p95_build_ms": round(p95, 2),
"preview_error_rate_pct": error_rate,
"preview_client_fetch_errors": _PREVIEW_REQUEST_ERROR_COUNT,
"preview_ttl_seconds": TTL_SECONDS,
"preview_ttl_adaptive": _ADAPTATION_ENABLED,
"preview_ttl_window": len(_RECENT_HITS),
"preview_last_bust_at": _PREVIEW_LAST_BUST_AT,
"role_distribution": role_distribution,
"editorial_curated_vs_sampled_pct": editorial_coverage_pct,
"example_enforcement_active": example_enforcement_active,
"example_enforce_threshold_pct": enforce_threshold,
"editorial_curated_total": _CURATED_GLOBAL,
"editorial_sampled_total": _SAMPLED_GLOBAL,
"per_theme": per_theme_stats,
"per_theme_errors": dict(list(_PREVIEW_PER_THEME_ERRORS.items())[:50]),
"curated_synergy_matrix_loaded": _CURATED_SYNERGY_MATRIX is not None,
"curated_synergy_matrix_size": sum(len(v) for v in _CURATED_SYNERGY_MATRIX.values()) if _CURATED_SYNERGY_MATRIX else 0,
}
def bust_preview_cache(reason: str | None = None) -> None:
"""Clear in-memory preview cache (e.g., after catalog rebuild or tagging).
Exposed for orchestrator hooks. Keeps metrics counters (requests/hits) for
observability; records last bust timestamp.
"""
global _PREVIEW_CACHE, _PREVIEW_LAST_BUST_AT
try: # defensive; never raise
_PREVIEW_CACHE.clear()
import time as _t
_PREVIEW_LAST_BUST_AT = _t.time()
except Exception:
pass

View file

@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<script>
// Ensure legacy hover system never initializes (set before its script executes)
window.__disableLegacyCardHover = true;
</script>
<script>
(function(){
// Pre-CSS theme bootstrapping to avoid flash/mismatch on first paint
@ -71,13 +75,14 @@
<span class="dot black"></span>
</div>
</div>
<nav class="nav">
<nav class="nav" id="primary-nav">
<a href="/">Home</a>
<a href="/build">Build</a>
<a href="/configs">Build from JSON</a>
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a>
<a href="/decks">Finished Decks</a>
<a href="/themes/">Themes</a>
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>
@ -117,6 +122,26 @@
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
.card-meta li { margin:.1rem 0; }
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
/* Global theme badge styles (moved from picker for reuse on standalone pages) */
.theme-badge { display:inline-block; padding:2px 6px; border-radius:12px; font-size:10px; background: var(--panel-alt); border:1px solid var(--border); letter-spacing:.5px; }
.theme-synergies { font-size:11px; opacity:.85; display:flex; flex-wrap:wrap; gap:4px; }
.badge-fallback { background:#7f1d1d; color:#fff; }
.badge-quality-draft { background:#4338ca; color:#fff; }
.badge-quality-reviewed { background:#065f46; color:#fff; }
.badge-quality-final { background:#065f46; color:#fff; font-weight:600; }
.badge-pop-vc { background:#065f46; color:#fff; }
.badge-pop-c { background:#047857; color:#fff; }
.badge-pop-u { background:#0369a1; color:#fff; }
.badge-pop-n { background:#92400e; color:#fff; }
.badge-pop-r { background:#7f1d1d; color:#fff; }
.badge-curated { background:#4f46e5; color:#fff; }
.badge-enforced { background:#334155; color:#fff; }
.badge-inferred { background:#57534e; color:#fff; }
.theme-detail-card { background:var(--panel); padding:1rem 1.1rem; border:1px solid var(--border); border-radius:10px; box-shadow:0 2px 6px rgba(0,0,0,.25); }
.theme-detail-card h3 { margin-top:0; margin-bottom:.4rem; }
.theme-detail-card .desc { margin-top:0; font-size:13px; line-height:1.45; }
.theme-detail-card h4 { margin-bottom:.35rem; margin-top:.85rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.85; }
.breadcrumb { font-size:12px; margin-bottom:.4rem; }
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
.card-meta .line + .line { margin-top:.35rem; }
@ -127,6 +152,51 @@
@media (max-width: 900px){
.card-hover{ display: none !important; }
}
.card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; }
.card-hover .ov-chip { display:inline-block; background:#0ea5e91a; color:#0ea5e9; border:1px solid #0ea5e9; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; }
/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */
.card-hover .dual.two-faced img { width:320px; }
.card-hover .dual.two-faced { gap:8px; }
/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */
.card-hover .dual.combo img { width:300px; }
@media (max-width: 1100px){
.card-hover .dual.two-faced img { width:280px; }
.card-hover .dual.combo img { width:260px; }
}
/* Unified hover-card-panel styling parity */
#hover-card-panel.is-payoff { border-color: var(--accent, #38bdf8); box-shadow:0 6px 24px rgba(0,0,0,.65), 0 0 0 1px var(--accent, #38bdf8) inset; }
#hover-card-panel.is-payoff .hcp-img { border-color: var(--accent, #38bdf8); }
/* Inline theme/tag list styling (unifies legacy second panel) */
/* Two-column hover layout */
#hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; }
#hover-card-panel .hcp-img-wrap { grid-column:1 / 2; }
#hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; }
/* Tag list as multi-column list instead of pill chips for readability */
#hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; }
#hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; }
#hover-card-panel .hcp-taglist li.overlap { font-weight:600; color:var(--accent,#38bdf8); }
#hover-card-panel .hcp-taglist li.overlap::before { content:'•'; color:var(--accent,#38bdf8); position:absolute; left:-10px; }
#hover-card-panel .hcp-overlaps { font-size:10px; line-height:1.25; margin-top:2px; }
#hover-card-panel .hcp-ov-chip { display:inline-block; background:var(--accent,#38bdf8); color:#fff; border:1px solid var(--accent,#38bdf8); border-radius:10px; padding:2px 5px; font-size:9px; margin-right:4px; margin-top:2px; }
/* Hide modal-specific close button outside modal host */
#preview-close-btn { display:none; }
#theme-preview-modal #preview-close-btn { display:inline-flex; }
/* Overlay flip toggle for double-faced cards */
.dfc-host { position:relative; }
.dfc-toggle { position:absolute; top:6px; left:6px; z-index:5; background:rgba(15,23,42,.82); color:#fff; border:1px solid #475569; border-radius:50%; width:36px; height:36px; padding:0; font-size:16px; cursor:pointer; line-height:1; display:flex; align-items:center; justify-content:center; opacity:.92; backdrop-filter: blur(3px); }
.dfc-toggle:hover, .dfc-toggle:focus { opacity:1; box-shadow:0 0 0 2px rgba(56,189,248,.35); outline:none; }
.dfc-toggle:active { transform: translateY(1px); }
.dfc-toggle .icon { font-size:12px; }
.dfc-toggle[data-face='back'] { background:rgba(76,29,149,.85); }
.dfc-toggle[data-face='front'] { background:rgba(15,23,42,.82); }
.dfc-toggle[aria-pressed='true'] { box-shadow:0 0 0 2px var(--accent, #38bdf8); }
/* Fade transition for hover panel image */
#hover-card-panel .hcp-img { transition: opacity .22s ease; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
</style>
<style>
.nav a.active { font-weight:600; position:relative; }
.nav a.active::after { content:''; position:absolute; left:0; bottom:2px; width:100%; height:2px; background:var(--accent, #38bdf8); border-radius:2px; }
</style>
<script>
(function(){
@ -230,6 +300,8 @@
pollHealth();
function ensureCard() {
// Legacy large image hover kept for fallback; disabled in favor of unified hover-card-panel
if (window.__disableLegacyCardHover) return document.getElementById('card-hover') || document.createElement('div');
var pop = document.getElementById('card-hover');
if (!pop) {
pop = document.createElement('div');
@ -256,9 +328,10 @@
}
var cardPop = ensureCard();
var PREVIEW_VERSIONS = ['normal','large'];
function buildCardUrl(name, version, nocache){
function buildCardUrl(name, version, nocache, face){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (face === 'back') url += '&face=back';
if (nocache) url += '&t=' + Date.now();
return url;
}
@ -325,6 +398,7 @@
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
}
function attachCardHover() {
if (window.__disableLegacyCardHover) return; // short-circuit legacy system
document.querySelectorAll('[data-card-name]').forEach(function(el) {
if (el.__cardHoverBound) return; // avoid duplicate bindings
el.__cardHoverBound = true;
@ -332,33 +406,62 @@
var img = cardPop.querySelector('.card-hover-inner img');
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
if (img2) img2.style.display = 'none';
var dualNode = cardPop.querySelector('.card-hover-inner .dual');
if (img2) { img2.style.display = 'none'; }
if (dualNode) { dualNode.classList.remove('combo','two-faced'); }
var meta = cardPop.querySelector('.card-meta');
var name = el.getAttribute('data-card-name') || '';
var vi = 0; // always start at 'normal' on hover
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false);
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false, 'front');
// Bind a one-off error handler per enter to try fallbacks
var triedNoCache = false;
function onErr(){
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false); }
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true); }
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false, 'front'); }
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true, 'front'); }
else { img.removeEventListener('error', onErr); }
}
img.addEventListener('error', onErr, { once:false });
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
// Attempt to load back face (double-faced / transform). If it fails, we silently hide.
if (img2) {
img2.style.display = 'none';
var backTriedNoCache = false;
var backIdx = 0;
function backErr(){
if (backIdx < PREVIEW_VERSIONS.length - 1){
backIdx += 1; img2.src = buildCardUrl(name, PREVIEW_VERSIONS[backIdx], false, 'back');
} else if (!backTriedNoCache){
backTriedNoCache = true; img2.src = buildCardUrl(name, PREVIEW_VERSIONS[backIdx], true, 'back');
} else {
img2.removeEventListener('error', backErr); img2.style.display='none';
}
}
function backOk(){ img2.removeEventListener('error', backErr); img2.removeEventListener('load', backOk); if (dualNode) dualNode.classList.add('two-faced'); img2.style.display=''; }
img2.addEventListener('error', backErr, { once:false });
img2.addEventListener('load', backOk, { once:false });
img2.src = buildCardUrl(name, PREVIEW_VERSIONS[0], false, 'back');
}
var role = el.getAttribute('data-role') || '';
var rawTags = el.getAttribute('data-tags') || '';
var overlapsRaw = el.getAttribute('data-overlaps') || '';
// Clean and split tags into an array; remove brackets and quotes
var tags = rawTags
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
.split(/\s*,\s*/)
.filter(function(t){ return t && t.trim(); });
var overlaps = overlapsRaw.split(/\s*,\s*/).filter(function(t){ return t; });
var overlapSet = new Set(overlaps);
if (role || (tags && tags.length)) {
var html = '';
if (role) {
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'&lt;') + '</div>';
}
if (tags && tags.length) {
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ return '<li>' + t.replace(/</g,'&lt;') + '</li>'; }).join('') + '</ul></div>';
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ var safe=t.replace(/</g,'&lt;'); return '<li'+(overlapSet.has(t)?' class="overlap"':'')+'>' + safe + '</li>'; }).join('') + '</ul></div>';
if (overlaps.length){
html += '<div class="line" style="margin-top:4px;"><span class="label" title="Themes shared with preview selection">Overlaps</span>' + overlaps.map(function(o){ return '<span class="ov-chip">'+o.replace(/</g,'&lt;')+'</span>'; }).join(' ') + '</div>';
}
}
meta.innerHTML = html;
meta.style.display = '';
@ -385,7 +488,10 @@
if (img2) img2.style.display = '';
var vi1 = 0, vi2 = 0; var triedNoCache1 = false, triedNoCache2 = false;
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false, 'front');
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false, 'front');
var dualNode = cardPop.querySelector('.card-hover-inner .dual');
if (dualNode){ dualNode.classList.add('combo'); dualNode.classList.remove('two-faced'); }
function err1(){ if (vi1 < PREVIEW_VERSIONS.length - 1){ vi1 += 1; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);} else if (!triedNoCache1){ triedNoCache1 = true; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], true);} else { img.removeEventListener('error', err1);} }
function err2(){ if (vi2 < PREVIEW_VERSIONS.length - 1){ vi2 += 1; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);} else if (!triedNoCache2){ triedNoCache2 = true; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], true);} else { img2.removeEventListener('error', err2);} }
img.addEventListener('error', err1, { once:false });
@ -404,6 +510,114 @@
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script>
// Overlay flip button + persistence + accessibility for double-faced cards
(function(){
var FACE_ATTR = 'data-current-face';
var LS_PREFIX = 'mtg:face:';
var DEBOUNCE_MS = 120; // prevent rapid flip spamming / extra fetches
var lastFlip = 0;
function hasTwoFaces(card){
if(!card) return false;
var name = (card.getAttribute('data-card-name')||'') + ' ' + (card.getAttribute('data-original-name')||'');
return name.indexOf('//') > -1;
}
function keyFor(card){
var nm = (card.getAttribute('data-card-name')|| card.getAttribute('data-original-name')||'').toLowerCase();
return LS_PREFIX + nm;
}
function applyStoredFace(card){
try {
var k = keyFor(card);
var val = localStorage.getItem(k);
if(val === 'front' || val === 'back') card.setAttribute(FACE_ATTR, val);
} catch(_){}
}
function storeFace(card, face){
try { localStorage.setItem(keyFor(card), face); } catch(_){}
}
function announce(face, card){
var live = document.getElementById('dfc-live');
if(!live){
live = document.createElement('div');
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
document.body.appendChild(live);
}
var nm = (card.getAttribute('data-card-name')||'').split('//')[0].trim();
live.textContent = 'Showing ' + (face==='front'?'front face':'back face') + ' of ' + nm;
}
function updateButton(btn, face){
btn.setAttribute('data-face', face);
btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face');
btn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;"></span>';
}
function ensureButton(card){
if(!hasTwoFaces(card)) return;
if(card.querySelector('.dfc-toggle')) return;
card.classList.add('dfc-host');
applyStoredFace(card);
var face = card.getAttribute(FACE_ATTR) || 'front';
var btn = document.createElement('button');
btn.type='button';
btn.className='dfc-toggle';
btn.setAttribute('aria-pressed','false');
btn.setAttribute('tabindex','0');
btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); });
btn.addEventListener('keydown', function(ev){ if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){ ev.preventDefault(); flip(card, btn); }});
updateButton(btn, face);
card.insertBefore(btn, card.firstChild);
}
function flip(card, btn){
var now = Date.now();
if(now - lastFlip < DEBOUNCE_MS) return;
lastFlip = now;
var cur = card.getAttribute(FACE_ATTR) || 'front';
var next = cur === 'front' ? 'back' : 'front';
card.setAttribute(FACE_ATTR, next);
storeFace(card, next);
if(btn) updateButton(btn, next);
// visual cue
card.style.outline='2px solid var(--accent)'; setTimeout(function(){ card.style.outline=''; }, 160);
announce(next, card);
// retrigger hover update under pointer if applicable
if(window.__hoverShowCard){ window.__hoverShowCard(card); }
}
function scan(){
document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile').forEach(ensureButton);
}
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
document.addEventListener('DOMContentLoaded', scan);
document.addEventListener('htmx:afterSwap', scan);
// Expose for debugging
window.__dfcScan = scan;
// MutationObserver to re-inject buttons if card tiles are replaced (e.g., HTMX swaps, dynamic filtering)
var moDebounce = null;
var observer = new MutationObserver(function(muts){
if(moDebounce) cancelAnimationFrame(moDebounce);
moDebounce = requestAnimationFrame(function(){ scan(); });
});
try { observer.observe(document.body, { childList:true, subtree:true }); } catch(_){ }
})();
</script>
<script>
(function(){
try {
var path = window.location.pathname || '/';
var nav = document.getElementById('primary-nav'); if(!nav) return;
var links = nav.querySelectorAll('a');
var best = null; var bestLen = -1;
links.forEach(function(a){
var href = a.getAttribute('href') || '';
if(!href) return;
// Exact match or prefix match (ignoring trailing slash)
if(path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){
if(href.length > bestLen){ best = a; bestLen = href.length; }
}
});
if(best) best.classList.add('active');
} catch(_) {}
})();
</script>
<script src="/static/app.js?v=20250826-4"></script>
{% if enable_themes %}
<script>
@ -521,5 +735,253 @@
}catch(_){ }
})();
</script>
<script>
// Global delegated hover card panel initializer (ensures functionality after HTMX swaps)
(function(){
// Disable legacy multi-element hover in favor of single unified panel
window.__disableLegacyCardHover = true;
// Global delegated curated-only & reasons controls (works after HTMX swaps and inline render)
function findPreviewRoot(el){ return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); }
function applyCuratedFor(root){
var checkbox = root.querySelector('#curated-only-toggle');
var status = root.querySelector('#preview-status');
if(!checkbox) return;
// persist
try{ localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1':'0'); }catch(_){ }
var curatedOnly = checkbox.checked;
var hidden=0;
root.querySelectorAll('.card-sample').forEach(function(card){
var role = card.getAttribute('data-role');
var isCurated = role==='example'|| role==='curated_synergy' || role==='synthetic';
if(curatedOnly && !isCurated){ card.style.display='none'; hidden++; } else { card.style.display=''; }
});
if(status) status.textContent = curatedOnly ? ('Hid '+hidden+' sampled cards') : '';
}
function applyReasonsFor(root){
var cb = root.querySelector('#reasons-toggle'); if(!cb) return;
try{ localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1':'0'); }catch(_){ }
var show = cb.checked;
root.querySelectorAll('[data-reasons-block]').forEach(function(el){ el.style.display = show ? '' : 'none'; });
}
document.addEventListener('change', function(e){
if(e.target && e.target.id === 'curated-only-toggle'){
var root = findPreviewRoot(e.target); if(root) applyCuratedFor(root);
}
});
document.addEventListener('change', function(e){
if(e.target && e.target.id === 'reasons-toggle'){
var root = findPreviewRoot(e.target); if(root) applyReasonsFor(root);
}
});
document.addEventListener('htmx:afterSwap', function(ev){
var frag = ev.target;
if(frag && frag.querySelector){
if(frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag);
if(frag.querySelector('#reasons-toggle')) applyReasonsFor(frag);
}
});
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('.preview-modal-content').forEach(function(root){
// restore persisted states before applying
try {
var cVal = localStorage.getItem('mtg:preview.curatedOnly');
if(cVal !== null){ var cb = root.querySelector('#curated-only-toggle'); if(cb){ cb.checked = cVal === '1'; } }
var rVal = localStorage.getItem('mtg:preview.showReasons');
if(rVal !== null){ var rb = root.querySelector('#reasons-toggle'); if(rb){ rb.checked = rVal === '1'; } }
}catch(_){ }
if(root.querySelector('#curated-only-toggle')) applyCuratedFor(root);
if(root.querySelector('#reasons-toggle')) applyReasonsFor(root);
});
});
function createPanel(){
var panel = document.createElement('div');
panel.id = 'hover-card-panel';
panel.setAttribute('role','dialog');
panel.setAttribute('aria-label','Card detail hover panel');
panel.setAttribute('aria-hidden','true');
panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:#1f2937;border:1px solid #374151;border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:#f3f4f6;font-size:14px;line-height:1.45;pointer-events:none;';
panel.innerHTML = ''+
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">'+
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">&nbsp;</div>'+
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>'+
'</div>'+
'<div class="hcp-body">'+
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">'+
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid #475569;background:#0b0d12;opacity:1;" />'+
'</div>'+
'<div class="hcp-right" style="display:flex;flex-direction:column;min-width:0;">'+
'<div style="display:flex;align-items:center;gap:6px;margin:0 0 4px;flex-wrap:wrap;">'+
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid #475569;border-radius:12px;background:#243044;text-transform:uppercase;">&nbsp;</div>'+
'<div class="hcp-overlaps" style="flex:1;min-height:14px;"></div>'+
'</div>'+
'<ul class="hcp-taglist" aria-label="Themes"></ul>'+
'<div class="hcp-meta" style="font-size:12px;opacity:.85;margin:2px 0 6px;"></div>'+
'<ul class="hcp-reasons" style="list-style:disc;margin:4px 0 8px 18px;padding:0;max-height:140px;overflow:auto;font-size:11px;line-height:1.35;"></ul>'+
'<div class="hcp-tags" style="font-size:11px;opacity:.55;word-break:break-word;"></div>'+
'</div>'+
'</div>';
document.body.appendChild(panel);
return panel;
}
function ensurePanel(){
var panel = document.getElementById('hover-card-panel');
if (panel) return panel;
// Auto-create for direct theme pages where fragment-specific markup not injected
return createPanel();
}
function setup(){
var panel = ensurePanel();
if(!panel || panel.__hoverInit) return;
panel.__hoverInit = true;
var imgEl = panel.querySelector('.hcp-img');
var nameEl = panel.querySelector('.hcp-name');
var rarityEl = panel.querySelector('.hcp-rarity');
var metaEl = panel.querySelector('.hcp-meta');
var reasonsList = panel.querySelector('.hcp-reasons');
var tagsEl = panel.querySelector('.hcp-tags');
function move(evt){
if(panel.style.display==='none') return;
var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad;
var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect();
if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad;
if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad;
panel.style.left = x+'px'; panel.style.top = y+'px';
}
// Lightweight image prefetch LRU cache (size 12) (P2 UI Hover image prefetch)
var _imgLRU=[];
function prefetch(src){ if(!src) return; if(_imgLRU.indexOf(src)===-1){ _imgLRU.push(src); if(_imgLRU.length>12) _imgLRU.shift(); var im=new Image(); im.src=src; } }
var activationDelay=120; // ms (P2 optional activation delay)
var hoverTimer=null;
function schedule(card, evt){ clearTimeout(hoverTimer); hoverTimer=setTimeout(function(){ show(card, evt); }, activationDelay); }
function cancelSchedule(){ clearTimeout(hoverTimer); }
var lastCard = null;
function show(card, evt){
if(!card) return;
// Prefer attributes on container, fallback to child (image) if missing
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
var rarity = (attr('data-rarity')||'').trim();
var mana = (attr('data-mana')||'').trim();
var role = (attr('data-role')||'').trim();
var reasonsRaw = attr('data-reasons')||'';
var tags = attr('data-tags')||'';
var roleEl = panel.querySelector('.hcp-role');
var tagListEl = panel.querySelector('.hcp-taglist');
var overlapsEl = panel.querySelector('.hcp-overlaps');
var overlapsAttr = attr('data-overlaps') || '';
var overlapArr = overlapsAttr.split(/\s*,\s*/).filter(Boolean);
nameEl.textContent = nm;
rarityEl.textContent = rarity;
metaEl.textContent = [role?('Role: '+role):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • ');
reasonsList.innerHTML='';
reasonsRaw.split(';').map(function(r){return r.trim();}).filter(Boolean).forEach(function(r){ var li=document.createElement('li'); li.style.margin='2px 0'; li.textContent=r; reasonsList.appendChild(li); });
// Build inline tag list with overlap highlighting
if(tagListEl){
tagListEl.innerHTML='';
if(tags){
var tagArr = tags.split(/\s*,\s*/).filter(Boolean);
var setOverlap = new Set(overlapArr);
tagArr.forEach(function(t){
var li = document.createElement('li');
if(setOverlap.has(t)) li.className='overlap';
li.textContent = t;
tagListEl.appendChild(li);
});
}
}
if(overlapsEl){
overlapsEl.innerHTML = overlapArr.map(function(o){ return '<span class="hcp-ov-chip" title="Overlapping synergy">'+o+'</span>'; }).join('');
}
tagsEl.textContent = tags; // raw tag string fallback (legacy consumers)
if(roleEl){ roleEl.textContent = role || ''; }
panel.classList.toggle('is-payoff', role === 'payoff');
var fuzzy = encodeURIComponent(nm);
var rawName = nm || '';
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
var storageKey = 'mtg:face:' + rawName.toLowerCase();
var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })();
if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace);
var chosenFace = card.getAttribute('data-current-face') || 'front';
(function(){
var desiredVersion='large';
var faceParam = (chosenFace==='back') ? '&face=back' : '';
var currentKey = nm+':'+chosenFace+':'+desiredVersion;
var prevFace = imgEl.getAttribute('data-face');
var faceChanged = prevFace && prevFace !== chosenFace;
if(imgEl.getAttribute('data-current')!== currentKey){
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
if(faceChanged){ imgEl.style.opacity = 0; }
prefetch(src);
imgEl.src = src;
imgEl.setAttribute('data-current', currentKey);
imgEl.setAttribute('data-face', chosenFace);
imgEl.addEventListener('load', function onLoad(){ imgEl.removeEventListener('load', onLoad); requestAnimationFrame(function(){ imgEl.style.opacity = 1; }); });
}
if(!imgEl.__errBound){
imgEl.__errBound = true;
imgEl.addEventListener('error', function(){
var cur = imgEl.getAttribute('src')||'';
if(cur.indexOf('version=large')>-1){ imgEl.src = cur.replace('version=large','version=normal'); }
else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); }
});
}
})();
panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt); lastCard = card;
}
function hide(){ panel.style.display='none'; panel.setAttribute('aria-hidden','true'); cancelSchedule(); }
document.addEventListener('mousemove', move);
function getCardFromEl(el){
if(!el) return null;
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
if(el.matches && el.matches('img.card-thumb')) return el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
return null;
}
document.addEventListener('pointerover', function(e){
var card = getCardFromEl(e.target);
if(!card) return;
// If hovering flip button, refresh immediately (no activation delay)
if(e.target.closest && e.target.closest('.dfc-toggle')){
show(card, e);
return;
}
if(lastCard === card && panel.style.display==='block') { return; }
schedule(card, e);
});
document.addEventListener('pointerout', function(e){
var relCard = getCardFromEl(e.relatedTarget);
if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
if(!panel.contains(e.relatedTarget)){
cancelSchedule();
if(!relCard) hide();
}
});
// Expose show function for external refresh (flip updates)
window.__hoverShowCard = function(card){
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
show(card, ev);
};
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell'); if(!next) hide(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape') hide(); });
// Compact mode event listener
document.addEventListener('mtg:hoverCompactToggle', function(){ panel.classList.toggle('compact-img', !!window.__hoverCompactMode); });
}
document.addEventListener('htmx:afterSwap', setup);
document.addEventListener('DOMContentLoaded', setup);
setup();
})();
</script>
<script>
// Global compact mode toggle function (UI Hover compact mode toggle)
(function(){
window.toggleHoverCompactMode = function(state){
if(typeof state==='boolean') window.__hoverCompactMode = state; else window.__hoverCompactMode = !window.__hoverCompactMode;
document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle'));
};
})();
</script>
</body>
</html>

View file

@ -7,6 +7,7 @@
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
<a class="action-button" href="/owned">Owned Library</a>
<a class="action-button" href="/decks">Finished Decks</a>
<a class="action-button" href="/themes/">Browse Themes</a>
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
</div>
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">

View file

@ -0,0 +1,121 @@
{% extends 'base.html' %}
{% block content %}
<h2>Theme Catalog (Simple)</h2>
<div id="theme-catalog-simple">
<div style="display:flex; gap:.75rem; flex-wrap:wrap; margin-bottom:.85rem; align-items:flex-end;">
<div style="flex:1; min-width:220px; position:relative;">
<label style="font-size:11px; display:block; opacity:.7;">Search</label>
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" style="width:100%;" autocomplete="off" />
<div id="theme-search-results" class="search-suggestions" style="position:absolute; top:100%; left:0; right:0; background:var(--panel); border:1px solid var(--border); border-top:none; z-index:25; display:none; max-height:300px; overflow:auto; border-radius:0 0 8px 8px;"></div>
</div>
<div style="min-width:160px;">
<label style="font-size:11px; display:block; opacity:.7;">Popularity</label>
<select id="pop-filter" style="width:100%; font-size:13px;">
<option value="">All</option>
<option>Very Common</option>
<option>Common</option>
<option>Uncommon</option>
<option>Niche</option>
<option>Rare</option>
</select>
</div>
<div style="min-width:210px;">
<label style="font-size:11px; display:block; opacity:.7;">Colors</label>
<div id="color-filter" style="display:flex; gap:.45rem; font-size:12px; flex-wrap:wrap;">
{% for c in ['W','U','B','R','G'] %}
<label style="display:flex; gap:2px; align-items:center;"><input type="checkbox" value="{{ c }}"/> {{ c }}</label>
{% endfor %}
</div>
</div>
<button id="clear-search" class="btn btn-ghost" style="font-size:12px;" hidden>Clear</button>
</div>
<div id="quick-popularity" style="display:flex; gap:.4rem; flex-wrap:wrap; margin-bottom:.55rem;">
{% for b in ['Very Common','Common','Uncommon','Niche','Rare'] %}
<button class="btn btn-ghost pop-chip" data-pop="{{ b }}" style="font-size:11px; padding:2px 8px;">{{ b }}</button>
{% endfor %}
</div>
<div id="active-filters" style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:.55rem; font-size:11px;"></div>
<div id="theme-results" aria-live="polite" aria-busy="true">
<div style="display:flex; flex-direction:column; gap:8px;">
{% for i in range(6) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
</div>
</div>
</div>
<style>
.search-suggestions a { display:block; padding:.5rem .6rem; font-size:13px; text-decoration:none; color:var(--text); border-bottom:1px solid var(--border); }
.search-suggestions a:last-child { border-bottom:none; }
.search-suggestions a:hover { background:var(--hover); }
</style>
<script>
(function(){
const input = document.getElementById('theme-search');
const resultsBox = document.getElementById('theme-search-results');
const clearBtn = document.getElementById('clear-search');
const popSel = document.getElementById('pop-filter');
const popChips = document.querySelectorAll('.pop-chip');
const colorBox = document.getElementById('color-filter');
const activeFilters = document.getElementById('active-filters');
const resultsHost = document.getElementById('theme-results');
let lastQuery=''; let lastSearchIssued=0; const SEARCH_THROTTLE=150;
function hideResults(){ resultsBox.style.display='none'; resultsBox.innerHTML=''; }
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 colors = Array.from(colorBox.querySelectorAll('input:checked')).map(c=>c.value); if(colors.length) params.set('colors', colors.join(','));
params.set('limit','50'); params.set('offset','0');
return params.toString();
}
function addChip(label, remover){
const span=document.createElement('span');
span.style.cssText='background:var(--panel-alt); border:1px solid var(--border); padding:2px 8px; border-radius:14px; display:inline-flex; align-items:center; gap:6px;';
span.innerHTML='<span>'+label+'</span><button style="background:none; border:none; cursor:pointer; font-size:12px;" aria-label="Remove">×</button>';
span.querySelector('button').addEventListener('click', remover);
activeFilters.appendChild(span);
}
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 colors = Array.from(colorBox.querySelectorAll('input:checked')).map(c=>c.value); if(colors.length) addChip('Colors: '+colors.join(','), ()=>{ colorBox.querySelectorAll('input:checked').forEach(i=>i.checked=false); fetchList(); });
}
function fetchList(){
const ps = buildParams();
resultsHost.setAttribute('aria-busy','true');
fetch('/themes/fragment/list_simple?'+ps, {cache:'no-store'})
.then(r=>r.text())
.then(html=>{ resultsHost.innerHTML=html; resultsHost.removeAttribute('aria-busy'); renderActive(); })
.catch(()=>{ resultsHost.innerHTML='<div class="empty" style="font-size:13px;">Failed to load.</div>'; resultsHost.removeAttribute('aria-busy'); });
}
function performSearch(q){
if(!q){ hideResults(); return; }
const now=Date.now(); if(now - lastSearchIssued < SEARCH_THROTTLE){ clearTimeout(window.__simpleSearchDelay); window.__simpleSearchDelay=setTimeout(()=>performSearch(q), SEARCH_THROTTLE); return; }
lastSearchIssued=now; const issueId=lastSearchIssued;
resultsBox.style.display='block';
resultsBox.innerHTML='<div style="padding:.5rem; font-size:12px; opacity:.7;">Searching…</div>';
fetch('/themes/api/search?q='+encodeURIComponent(q), {cache:'no-store'})
.then(r=>r.json()).then(js=>{
if(issueId!==lastSearchIssued) return;
if(!js.ok){ hideResults(); return; }
const items=js.items||[]; if(!items.length){ hideResults(); return; }
resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme}</a>`).join('');
resultsBox.style.display='block';
}).catch(()=>hideResults());
}
input.addEventListener('input', function(){
const q=this.value.trim(); clearBtn.hidden=!q; if(q!==lastQuery){ lastQuery=q; performSearch(q); fetchList(); }
});
input.addEventListener('keydown', function(ev){
if(ev.key==='Enter' && ev.shiftKey){ const q=input.value.trim(); if(!q) return; ev.preventDefault(); fetch('/themes/api/search?q='+encodeURIComponent(q)+'&include_synergies=1',{cache:'no-store'}).then(r=>r.json()).then(js=>{ if(!js.ok) return; const items=js.items||[]; if(!items.length) return; resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme} <span style=\"opacity:.55; font-size:10px;\">(w/ synergies)</span></a>`).join(''); resultsBox.style.display='block'; }); }
});
clearBtn.addEventListener('click', function(){ input.value=''; lastQuery=''; hideResults(); clearBtn.hidden=true; input.focus(); fetchList(); });
resultsBox.addEventListener('click', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; ev.preventDefault(); window.location.href='/themes/'+a.getAttribute('data-theme-id'); });
resultsBox.addEventListener('mouseover', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; const id=a.getAttribute('data-theme-id'); if(!id || a._prefetched) return; a._prefetched=true; 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(); }));
colorBox.addEventListener('change', fetchList);
// Initial load
fetchList();
})();
</script>
{% endblock %}

View file

@ -0,0 +1,84 @@
{% if theme %}
<div class="theme-detail-card">
{% if standalone_page %}
<div class="breadcrumb"><a href="/themes/" class="btn btn-ghost" style="font-size:11px; padding:2px 6px;">← Catalog</a></div>
{% endif %}
<h3 id="theme-detail-heading-{{ theme.id }}" tabindex="-1">{{ theme.theme }}
{% if diagnostics and yaml_available %}
<a href="/themes/yaml/{{ theme.id }}" target="_blank" style="font-size:11px; font-weight:400; margin-left:.5rem;">(YAML)</a>
{% endif %}
</h3>
{% if theme.description %}
<p class="desc">{{ theme.description }}</p>
{% else %}
{% if theme.synergies %}
<p class="desc" data-fallback-desc="1">Built around {{ theme.synergies[:6]|join(', ') }}.</p>
{% else %}
<p class="desc" data-fallback-desc="1">No description.</p>
{% endif %}
{% endif %}
<div style="font-size:12px; margin-bottom:.5rem; display:flex; gap:8px; flex-wrap: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 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>
<div class="synergy-section">
<h4>Synergies {% if not uncapped %}(capped){% endif %}</h4>
<div class="theme-synergies">
{% for s in theme.synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
</div>
{% if diagnostics %}
{% if not uncapped and theme.uncapped_synergies %}
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1&uncapped=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Show Uncapped ({{ theme.uncapped_synergies|length }})</button>
{% elif uncapped %}
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Hide Uncapped</button>
{% if theme.uncapped_synergies %}
<div class="theme-synergies" style="margin-top:.4rem;">
{% for s in theme.uncapped_synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
</div>
{% endif %}
{% endif %}
{% endif %}
</div>
<div class="examples" style="margin-top:.75rem;">
<h4 style="margin-bottom:.4rem;">Example Cards</h4>
<div class="example-card-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
{% if theme.example_cards %}
{% for c in theme.example_cards %}
<div class="ex-card card-sample" style="text-align:center;" data-card-name="{{ c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}">
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ c|urlencode }}&format=image&version=small" />
<div style="font-size:11px; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600;" class="card-ref" data-card-name="{{ c }}" data-tags="{{ theme.synergies|join(', ') }}">{{ c }}</div>
</div>
{% endfor %}
{% else %}
<div style="font-size:12px; opacity:.7;">No curated example cards.</div>
{% endif %}
</div>
<h4 style="margin:.9rem 0 .4rem;">Example Commanders</h4>
<div class="example-commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
{% if theme.example_commanders %}
{% for c in theme.example_commanders %}
<div class="ex-commander commander-cell" style="text-align:center;" data-card-name="{{ c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}">
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ c|urlencode }}&format=image&version=small" />
<div style="font-size:11px; margin-top:4px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" class="card-ref" data-card-name="{{ c }}" data-tags="{{ theme.synergies|join(', ') }}">{{ c }}</div>
</div>
{% endfor %}
{% else %}
<div style="font-size:12px; opacity:.7;">No curated commander examples.</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="empty">Theme not found.</div>
{% endif %}
<style>
.card-ref { cursor:pointer; text-decoration:underline dotted; }
.card-ref:hover { color:var(--accent); }
</style>
<script>
// Accessibility: automatically move focus to the detail heading after the fragment is swapped in
(function(){
try { var h=document.getElementById('theme-detail-heading-{{ theme.id }}'); if(h){ h.focus({preventScroll:false}); } } catch(_e){}
})();
</script>

View file

@ -0,0 +1,4 @@
{% extends 'base.html' %}
{% block content %}
{% include 'themes/detail_fragment.html' %}
{% endblock %}

View file

@ -0,0 +1,155 @@
{% if items %}
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:.35rem; font-size:12px;">
<div>
Showing {{ offset + 1 }}{{ (offset + items|length) }} of {{ total }}
</div>
<div class="pager-buttons" style="display:flex; gap:.4rem;">
{% if prev_offset is not none %}
<button hx-get="/themes/fragment/list?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
{% else %}
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>
{% endif %}
{% if next_offset is not none %}
<button hx-get="/themes/fragment/list?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
{% else %}
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>
{% endif %}
</div>
</div>
<table>
<thead>
<tr>
<th style="width:22%">Theme</th>
<th style="width:10%">Primary</th>
<th style="width:10%">Secondary</th>
<th style="width:12%">Popularity</th>
<th style="width:12%">Archetype</th>
<th>Synergies</th>
</tr>
</thead>
<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 %}
{% 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 %}
</td>
<td>{% if it.primary_color %}<span aria-label="Primary color: {{ it.primary_color }}">{{ it.primary_color }}</span>{% endif %}</td>
<td>{% if it.secondary_color %}<span aria-label="Secondary color: {{ it.secondary_color }}">{{ it.secondary_color }}</span>{% endif %}</td>
<td>
{% if it.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>
<td>{{ it.deck_archetype or '' }}</td>
<td>
<div class="theme-synergies">
{% for s in it.synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
{% if it.synergies_capped %}<span class="theme-badge" title="Additional synergies hidden"></span>{% endif %}
</div>
<div style="margin-top:4px;">
<button
data-preview-btn
data-theme-id="{{ it.id }}"
hx-get="/themes/fragment/preview/{{ it.id }}"
hx-target="#theme-preview-modal"
hx-swap="innerHTML"
onclick="(function(){var m=document.getElementById('theme-preview-modal'); if(!m){ m=document.createElement('div'); m.id='theme-preview-modal'; m.className='preview-modal'; m.innerHTML='<div class=\'preview-modal-content\'>Loading…</div>'; document.body.appendChild(m);} m.style.display='block';})();"
style="font-size:10px; padding:2px 6px; margin-top:2px;">Preview</button>
{% if it.synergies_capped %}
<button
hx-get="/themes/fragment/detail/{{ it.id }}"
hx-target="#theme-detail"
hx-swap="innerHTML"
title="Show full synergy list in details panel"
style="font-size:10px; padding:2px 6px; margin-top:2px;">+ All</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-top:.5rem; font-size:12px;">
<div>
Showing {{ offset + 1 }}{{ (offset + items|length) }} of {{ total }}
</div>
<div class="pager-buttons" style="display:flex; gap:.4rem;">
{% if prev_offset is not none %}
<button hx-get="/themes/fragment/list?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
{% else %}
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>
{% endif %}
{% if next_offset is not none %}
<button hx-get="/themes/fragment/list?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
{% else %}
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>
{% endif %}
</div>
</div>
<div id="theme-detail" class="theme-detail" style="margin-top:1rem;">Select a theme above to view details.</div>
<script>
// Enhance preview button with sessionStorage fragment cache (ETag aware) + structured logs
(function(){
try {
var store = window.sessionStorage;
var buttons = document.querySelectorAll('button[data-preview-btn]');
function log(ev){ if(!window.THEME_DIAG_ENABLED) return; try { fetch('/themes/log',{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({event:ev, ts:Date.now()})}); } catch(_e){} }
buttons.forEach(function(btn){
if(btn.getAttribute('data-cache-enhanced')) return;
btn.setAttribute('data-cache-enhanced','1');
btn.addEventListener('click', function(ev){
var theme = btn.getAttribute('data-theme-id');
if(!theme) return;
var key = 'preview:'+theme+':limit12';
try {
var cached = store.getItem(key);
if(cached){
var parsed = JSON.parse(cached);
if(parsed && parsed.html && parsed.etag){
log('cache_hit');
// Optimistic render cached first, then revalidate
var host = document.getElementById('theme-preview-modal');
if(host){ host.innerHTML = parsed.html; }
fetch('/themes/fragment/preview/'+theme, { headers:{'If-None-Match': parsed.etag}}).then(function(r){
if(r.status === 304) return null; return r.text().then(function(ht){ return {ht:ht, et:r.headers.get('ETag')}; });
}).then(function(obj){ if(!obj) return; var host2=document.getElementById('theme-preview-modal'); if(host2){ host2.innerHTML=obj.ht; } store.setItem(key, JSON.stringify({html: obj.ht, etag: obj.et || ''})); }).catch(function(){});
return; // short-circuit default htmx fetch (we already handled)
}
}
log('cache_miss');
} catch(_e){}
// No cache path: allow htmx; hook after swap to store
document.addEventListener('htmx:afterSwap', function handler(e){
if(e.target && e.target.id==='theme-preview-modal'){
try {
var et = e.detail.xhr.getResponseHeader('ETag') || '';
store.setItem(key, JSON.stringify({html: e.target.innerHTML, etag: et}));
} catch(_){}
document.removeEventListener('htmx:afterSwap', handler);
}
});
}, {capture:true});
});
} catch(_err){}
})();
</script>
{% else %}
{% if total == 0 %}
<div class="empty">No themes match your filters.</div>
{% else %}
<div class="skeleton-table" style="display:flex; flex-direction:column; gap:6px;">
{% for i in range(6) %}
<div class="skeleton-row" style="display:grid; grid-template-columns:22% 10% 10% 12% 12% 1fr; gap:8px; align-items:center;">
<div class="sk-cell sk-wide" style="height:14px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-cell sk-long" style="height:18px; background:var(--hover); border-radius:4px;"></div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}

View file

@ -0,0 +1,44 @@
{% if items %}
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:.5rem; font-size:12px;">
<div>Showing {{ offset + 1 }}{{ (offset + items|length) }} of {{ total }}</div>
<div style="display:flex; gap:.4rem;">
{% if prev_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
{% if next_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
</div>
</div>
<ul class="theme-simple-list" style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.65rem;">
{% for it in items %}
<li style="padding:.6rem .75rem; border:1px solid var(--border); border-radius:8px; background:var(--panel-alt);">
<a href="/themes/{{ it.id }}" style="font-weight:600; font-size:14px; text-decoration:none; color:var(--text);">{{ it.theme }}</a>
{% if it.short_description %}<div style="font-size:12px; opacity:.85; margin-top:2px;">{{ it.short_description }}</div>{% endif %}
</li>
{% endfor %}
</ul>
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-top:.75rem; font-size:12px;">
<div>Showing {{ offset + 1 }}{{ (offset + items|length) }} of {{ total }}</div>
<div style="display:flex; gap:.4rem;">
{% if prev_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
{% if next_offset is not none %}
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
</div>
</div>
{% else %}
{% if total == 0 %}
<div class="empty" style="font-size:13px;">No themes found.</div>
{% else %}
<div style="display:flex; flex-direction:column; gap:8px;">
{% for i in range(8) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
</div>
{% endif %}
{% endif %}
<style>
@keyframes sk {0%{background-position:0 0;}100%{background-position:-200% 0;}}
.theme-simple-list li:hover { background:var(--hover); }
</style>

View file

@ -0,0 +1,403 @@
{% extends 'base.html' %}
{% block content %}
<h2 style="position:relative;">Theme Catalog <small id="theme-stale-indicator" style="display:none; font-size:12px; color:#b45309;">(Refreshing…)</small></h2>
<div id="theme-picker" class="theme-picker" hx-get="/themes/fragment/list?limit=20&offset=0" hx-trigger="load" hx-target="#theme-results" hx-swap="innerHTML" role="region" aria-label="Theme picker">
<div class="theme-picker-controls">
<input type="text" id="theme-search" placeholder="Search themes or synergies" aria-label="Search"
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="keyup changed delay:250ms" name="q" />
<select id="theme-archetype" name="archetype" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change">
<option value="">All Archetypes</option>
{% if archetypes %}{% for a in archetypes %}<option value="{{ a }}">{{ a }}</option>{% endfor %}{% endif %}
</select>
<select id="theme-bucket" name="bucket" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change">
<option value="">All Popularity</option>
<option>Very Common</option>
<option>Common</option>
<option>Uncommon</option>
<option>Niche</option>
<option>Rare</option>
</select>
<select id="theme-limit" name="limit" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change" title="Themes per page">
<option value="20" selected>20</option>
<option value="30">30</option>
<option value="50">50</option>
<option value="75">75</option>
<option value="100">100</option>
</select>
<label title="Show full synergy list (diagnostics only)"><input type="checkbox" id="synergy-full" name="synergy_mode" value="full" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change"/> Full Synergies</label>
<label title="Search input responsiveness experiment">
Mode:
<select id="search-mode">
<option value="throttle" selected>Throttle 250ms</option>
<option value="debounce">Debounce 250ms</option>
</select>
</label>
<div class="color-filters" role="group" aria-label="Filter by primary/secondary color">
{% for c in ['W','U','B','R','G'] %}
<label><input type="checkbox" name="colors" value="{{ c }}"
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change"/> {{ c }}</label>
{% endfor %}
</div>
{% if theme_picker_diagnostics %}
<label title="Show diagnostics-only badges"><input type="checkbox" id="diag-toggle" name="diagnostics" value="1" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change"/> Diagnostics</label>
{% endif %}
</div>
<div id="filter-chips" class="filter-chips" aria-label="Active filters" style="margin-bottom:.25rem;"></div>
<div id="theme-results" class="theme-results preload-hide" aria-live="polite" aria-busy="true" role="listbox" aria-label="Loading themes">
<div class="skeleton-table" aria-hidden="true">
{% for i in range(6) %}
<div class="skeleton-row skeleton-align">
<div class="sk-cell sk-col sk-theme"></div>
<div class="sk-cell sk-col sk-arch"></div>
<div class="sk-cell sk-col sk-pop"></div>
<div class="sk-cell sk-col sk-colors"></div>
<div class="sk-cell sk-col sk-cnt"></div>
<div class="sk-cell sk-col sk-synergies"></div>
</div>
{% endfor %}
</div>
</div>
<template id="theme-row-template"></template>
<div class="legend" style="margin-top:1rem; font-size:12px; line-height:1.3;">
<strong>Legend:</strong>
<span class="theme-badge badge-enforced" title="Enforced synergy (whitelist governance)">ENF</span>
<span class="theme-badge badge-curated" title="Curated synergy (hand authored)">CUR</span>
<span class="theme-badge badge-inferred" title="Inferred synergy (analytics)">INF</span>
<span class="theme-badge badge-pop-vc" title="Popularity: Very Common">VC</span>
<span class="theme-badge badge-pop-c" title="Popularity: Common">C</span>
<span class="theme-badge badge-pop-u" title="Popularity: Uncommon">U</span>
<span class="theme-badge badge-pop-n" title="Popularity: Niche">N</span>
<span class="theme-badge badge-pop-r" title="Popularity: Rare">R</span>
{% if theme_picker_diagnostics %}
<span class="theme-badge badge-fallback" title="Generic fallback description"></span>
<span class="theme-badge badge-quality-draft" title="Editorial quality: draft">D</span>
<span class="theme-badge badge-quality-reviewed" title="Editorial quality: reviewed">R</span>
<span class="theme-badge badge-quality-final" title="Editorial quality: final">F</span>
{% endif %}
</div>
<div id="theme-preview-host"></div>
<div id="filter-chips" class="filter-chips" aria-label="Active filters"></div>
</div>
<style>
.theme-picker-controls { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:.75rem; }
.theme-results table { width:100%; border-collapse: collapse; }
.theme-results th, .theme-results td { padding:.35rem .5rem; border-bottom:1px solid var(--border); font-size:13px; }
.theme-results tr:hover { background: var(--hover); cursor:pointer; }
:root { --focus:#6366f1; }
@media (prefers-contrast: more){ :root { --focus:#ff9800; } }
.theme-row.is-active { outline:2px solid var(--focus); outline-offset:-2px; background:var(--hover); }
/* Long theme name truncation */
.theme-results td:first-child { max-width:260px; }
.theme-results td:first-child span.trunc-name { display:inline-block; max-width:240px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; vertical-align:bottom; }
/* Badge wrapping heuristics */
.theme-synergies .theme-badge { max-width:120px; overflow:hidden; text-overflow:ellipsis; }
.theme-synergies { font-size:11px; opacity:.85; display:flex; flex-wrap:wrap; gap:4px; }
.theme-badge { display:inline-block; padding:2px 6px; border-radius:12px; font-size:10px; background: var(--panel-alt); border:1px solid var(--border); letter-spacing:.5px; }
.badge-fallback { background:#7f1d1d; color:#fff; }
.badge-quality-draft { background:#4338ca; color:#fff; }
.badge-quality-reviewed { background:#065f46; color:#fff; }
.badge-quality-final { background:#065f46; color:#fff; font-weight:600; }
.badge-pop-vc { background:#065f46; color:#fff; }
.badge-pop-c { background:#047857; color:#fff; }
.badge-pop-u { background:#0369a1; color:#fff; }
.badge-pop-n { background:#92400e; color:#fff; }
.badge-pop-r { background:#7f1d1d; color:#fff; }
.badge-curated { background:#4f46e5; color:#fff; }
.badge-enforced { background:#334155; color:#fff; }
.badge-inferred { background:#57534e; color:#fff; }
/* Preview modal */
.preview-modal { position:fixed; inset:0; background:rgba(0,0,0,0.55); display:flex; align-items:flex-start; justify-content:center; padding:4vh 2vw; z-index:9000; }
.preview-modal-content { background:var(--panel); padding:1rem; border-radius:8px; max-width:900px; width:100%; max-height:88vh; overflow:auto; box-shadow:0 4px 18px rgba(0,0,0,0.4); }
/* Skeleton */
.skeleton-table { display:flex; flex-direction:column; gap:6px; }
.skeleton-row { display:grid; grid-template-columns:22% 10% 10% 12% 12% 1fr; gap:8px; align-items:center; }
.sk-cell { height:14px; background:linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite; border-radius:4px; opacity:.7; }
.skeleton-align .sk-col { height:14px; }
.sk-synergies { height:18px; }
/* New UX additions */
.filter-chips { display:flex; gap:6px; flex-wrap:wrap; margin-top:.35rem; }
.filter-chip { background:var(--panel-alt); border:1px solid var(--border); padding:2px 8px; border-radius:14px; font-size:11px; cursor:pointer; display:inline-flex; align-items:center; gap:4px; }
.filter-chip button { background:none; border:none; color:inherit; cursor:pointer; font-size:11px; padding:0; line-height:1; }
mark { background:#fde68a; color:inherit; padding:0 2px; border-radius:2px; }
@keyframes sk {0%{background-position:0 0;}100%{background-position:-200% 0;}}
</style>
<script>
(function(){
function serializeFilters(container){
var params = [];
var qs = container.querySelector('#theme-search');
if(qs && qs.value.trim()){ params.push('q='+encodeURIComponent(qs.value.trim())); }
var as = container.querySelector('#theme-archetype');
if(as && as.value) params.push('archetype='+encodeURIComponent(as.value));
var bs = container.querySelector('#theme-bucket');
if(bs && bs.value) params.push('bucket='+encodeURIComponent(bs.value));
var lim = container.querySelector('#theme-limit');
if(lim && lim.value) params.push('limit='+encodeURIComponent(lim.value));
var diag = container.querySelector('#diag-toggle');
if(diag && diag.checked) params.push('diagnostics=1');
var syFull = container.querySelector('#synergy-full');
if(syFull && syFull.checked) params.push('synergy_mode=full');
var colorChecks = container.querySelectorAll('input[name="colors"]:checked');
if(colorChecks.length){
var vals = Array.prototype.map.call(colorChecks, c=>c.value).join(',');
params.push('colors='+encodeURIComponent(vals));
}
return params.join('&');
}
var perfMarks={}; function mark(n){ perfMarks[n]=performance.now(); }
function fetchList(){
saveScroll();
var container = document.getElementById('theme-picker');
if(!container) return;
var target = document.getElementById('theme-results');
if(!target) return;
// Abort any in-flight request (resilience: rapid search)
if(window.__themeListAbort){ try { window.__themeListAbort.abort(); } catch(_e){} }
var controller = new AbortController();
window.__themeListAbort = controller;
target.setAttribute('aria-busy','true');
target.setAttribute('aria-label','Loading themes');
mark('list_render_start');
var base = serializeFilters(container);
if(base.indexOf('offset=') === -1){ base += (base ? '&' : '') + 'offset=0'; }
toggleRefreshBtn(true);
fetch('/themes/fragment/list?'+base, {cache:'no-store', signal: controller.signal})
.then(r=>r.text())
.then(html=>{ if(controller.signal.aborted) return; target.innerHTML = html; target.removeAttribute('aria-busy'); target.classList.remove('preload-hide'); listRenderComplete(); toggleRefreshBtn(false); })
.catch(err=>{ if(controller.signal.aborted) return; target.innerHTML = '<div class="error" role="alert">Failed loading themes. <button id="retry-fetch" class="btn btn-ghost">Retry</button></div>'; target.removeAttribute('aria-busy'); target.classList.remove('preload-hide'); attachRetry(); structuredLog('list_fetch_error'); announceResultCount(); toggleRefreshBtn(false); });
}
function attachRetry(){ var b=document.getElementById('retry-fetch'); if(!b) return; b.addEventListener('click', function(){ fetchList(); }); }
function listRenderComplete(){ mark('list_ready'); announceResultCount(); if(window.THEME_DIAG_ENABLED){ try { var dur=perfMarks.list_ready - perfMarks.list_render_start; if(navigator.sendBeacon){ navigator.sendBeacon('/themes/metrics/client', new Blob([JSON.stringify({events:[{name:'list_render', duration_ms:Math.round(dur)}]})], {type:'application/json'})); } } catch(_e){} } }
function injectPrefetchLinks(){
try {
var head=document.head; if(!head) return;
// Remove old dynamic prefetch links
Array.from(head.querySelectorAll('link[data-dynamic-prefetch]')).forEach(l=>l.remove());
// Choose top 5 rows (skip currently selected to bias exploration)
var rows = document.querySelectorAll('#theme-results tr.theme-row[data-theme-id]');
var current = new URL(window.location.href).searchParams.get('theme');
var picked=[]; rows.forEach(r=>{ var id=r.getAttribute('data-theme-id'); if(!id) return; if(id===current) return; if(picked.length<5) picked.push(id); });
picked.forEach(function(id){ var link=document.createElement('link'); link.rel='prefetch'; link.href='/themes/fragment/detail/'+id; link.as='fetch'; link.setAttribute('data-dynamic-prefetch','1'); head.appendChild(link); });
} catch(e) {}
}
function structuredLog(ev){ if(!window.THEME_DIAG_ENABLED) return; try { fetch('/themes/log',{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({event:ev, ts:Date.now()})}); } catch(_e){} }
document.addEventListener('htmx:afterOnLoad', function(ev){
if(ev.target && ev.target.id==='theme-results'){
var rows = ev.target.querySelectorAll('tr.theme-row[data-theme-id]');
rows.forEach(function(r){
var id = r.getAttribute('data-theme-id');
if(!id) return;
r.addEventListener('click', function(){
var url = new URL(window.location.href);
url.searchParams.set('theme', id);
var filters = serializeFilters(document.getElementById('theme-picker'));
if(filters){ url.searchParams.set('filters', filters); }
history.pushState({ theme:id, filters: filters }, '', url.toString()); window._lastThemeFocusId=id;
}, { once:false });
var prefetchTimer;
r.addEventListener('mouseenter', function(){
clearTimeout(prefetchTimer);
prefetchTimer = setTimeout(function(){
fetch('/themes/fragment/detail/'+id+'?'+(document.getElementById('diag-toggle')?.checked?'diagnostics=1':''), {cache:'force-cache'}).then(function(resp){ structuredLog('prefetch_success'); return resp.text(); }).then(function(_html){ }).catch(function(){ structuredLog('prefetch_error'); });
}, 180);
});
r.addEventListener('mouseleave', function(){ clearTimeout(prefetchTimer); });
});
var current = new URL(window.location.href).searchParams.get('theme');
if(current && !document.getElementById('theme-detail')?.dataset?.loaded){
htmx.ajax('GET', '/themes/fragment/detail/'+current, '#theme-detail');
}
// Restore focus to previously active row if available
if(window._lastThemeFocusId){
var targetRow = ev.target.querySelector('tr.theme-row[data-theme-id="'+window._lastThemeFocusId+'"]');
if(targetRow){ targetRow.focus({preventScroll:false}); }
}
buildFilterChips();
injectPrefetchLinks();
restoreScroll();
enableKeyboardNav();
}
});
window.addEventListener('popstate', function(ev){
var state = ev.state || {};
if(state.filters){
var params = new URLSearchParams(state.filters);
var container = document.getElementById('theme-picker');
if(container){
if(params.get('q')) container.querySelector('#theme-search').value = decodeURIComponent(params.get('q'));
if(params.get('archetype')) container.querySelector('#theme-archetype').value = decodeURIComponent(params.get('archetype'));
if(params.get('bucket')) container.querySelector('#theme-bucket').value = decodeURIComponent(params.get('bucket'));
if(params.get('limit')) container.querySelector('#theme-limit').value = decodeURIComponent(params.get('limit'));
var colorStr = params.get('colors');
if(colorStr){
var set = new Set(colorStr.split(','));
container.querySelectorAll('input[name="colors"]').forEach(function(cb){ cb.checked = set.has(cb.value); });
}
if(params.get('diagnostics')==='1'){ var diag=container.querySelector('#diag-toggle'); if(diag){ diag.checked=true; } }
fetchList();
}
}
if(state.theme){
htmx.ajax('GET', '/themes/fragment/detail/'+state.theme, '#theme-detail');
}
});
window.addEventListener('load', function(){
var url = new URL(window.location.href);
var filters = url.searchParams.get('filters');
if(filters){
var params = new URLSearchParams(filters);
var container = document.getElementById('theme-picker');
if(container){
if(params.get('q')) container.querySelector('#theme-search').value = decodeURIComponent(params.get('q'));
if(params.get('archetype')) container.querySelector('#theme-archetype').value = decodeURIComponent(params.get('archetype'));
if(params.get('bucket')) container.querySelector('#theme-bucket').value = decodeURIComponent(params.get('bucket'));
if(params.get('limit')) container.querySelector('#theme-limit').value = decodeURIComponent(params.get('limit'));
var colorStr = params.get('colors');
if(colorStr){
var set = new Set(colorStr.split(','));
container.querySelectorAll('input[name="colors"]').forEach(function(cb){ cb.checked = set.has(cb.value); });
}
if(params.get('diagnostics')==='1'){ var diag=container.querySelector('#diag-toggle'); if(diag){ diag.checked=true; } }
}
}
var theme = url.searchParams.get('theme');
if(theme){ htmx.ajax('GET','/themes/fragment/detail/'+theme,'#theme-detail'); }
window.THEME_DIAG_ENABLED = !!document.getElementById('diag-toggle');
}, { once:true });
var lastScroll = 0;
function saveScroll(){ lastScroll = window.scrollY || document.documentElement.scrollTop; }
function restoreScroll(){ if(typeof lastScroll === 'number'){ window.scrollTo(0, lastScroll); } }
function buildFilterChips(){
var host = document.getElementById('filter-chips');
if(!host) return;
host.innerHTML='';
var container = document.getElementById('theme-picker');
if(!container) return;
var q = container.querySelector('#theme-search').value.trim();
var archetype = container.querySelector('#theme-archetype').value;
var bucket = container.querySelector('#theme-bucket').value;
var colors = Array.from(container.querySelectorAll('input[name="colors"]:checked')).map(c=>c.value).join(',');
var diag = container.querySelector('#diag-toggle')?.checked;
function addChip(label, key){
var chip = document.createElement('span');
chip.className='filter-chip';
chip.innerHTML = '<span>'+label+'</span><button aria-label="Remove '+key+'">×</button>';
chip.querySelector('button').addEventListener('click', function(){
if(key==='q'){ container.querySelector('#theme-search').value=''; }
if(key==='archetype'){ container.querySelector('#theme-archetype').value=''; }
if(key==='bucket'){ container.querySelector('#theme-bucket').value=''; }
if(key==='colors'){ container.querySelectorAll('input[name="colors"]').forEach(cb=>cb.checked=false); }
if(key==='diagnostics'){ var d=container.querySelector('#diag-toggle'); if(d) d.checked=false; }
fetchList();
});
host.appendChild(chip);
}
if(q) addChip('Search: '+q, 'q');
if(archetype) addChip('Archetype: '+archetype, 'archetype');
if(bucket) addChip('Popularity: '+bucket, 'bucket');
if(colors) addChip('Colors: '+colors, 'colors');
if(diag) addChip('Diagnostics', 'diagnostics');
var syFull = container.querySelector('#synergy-full')?.checked; if(syFull) addChip('Full Synergies','synergy_mode');
}
function enableKeyboardNav(){
var tbody = document.querySelector('#theme-results tbody');
if(!tbody) return;
var rows = Array.from(tbody.querySelectorAll('tr.theme-row'));
if(!rows.length) return;
var activeIndex = -1;
function setActive(i){ rows.forEach(r=>r.classList.remove('is-active')); if(i>=0 && rows[i]){ rows[i].classList.add('is-active'); rows[i].focus({preventScroll:true}); activeIndex=i; } }
document.addEventListener('keydown', function(e){
if(['ArrowDown','ArrowUp','Enter','Escape'].indexOf(e.key)===-1) return;
if(e.key==='ArrowDown'){ e.preventDefault(); setActive(Math.min(activeIndex+1, rows.length-1)); }
else if(e.key==='ArrowUp'){ e.preventDefault(); setActive(Math.max(activeIndex-1, 0)); }
else if(e.key==='Enter'){ if(activeIndex>=0){ rows[activeIndex].click(); } }
else if(e.key==='Escape'){ setActive(-1); var detail=document.getElementById('theme-detail'); if(detail){ detail.innerHTML='Selection cleared.'; detail.setAttribute('aria-live','polite'); } }
}, { once:false });
document.addEventListener('keydown', function(e){
if(e.key==='Enter' && e.shiftKey){ var cb=document.getElementById('synergy-full'); if(cb){ cb.checked=!cb.checked; fetchList(); } }
});
}
var searchInput = document.getElementById('theme-search');
var searchModeSel = document.getElementById('search-mode');
var lastExec = 0; var pendingTimer = null; var BASE_DELAY = 250;
function performSearch(){ fetchList(); }
function throttledHandler(){ var now=Date.now(); if(now - lastExec > BASE_DELAY){ lastExec = now; performSearch(); } }
function debouncedHandler(){ clearTimeout(pendingTimer); pendingTimer = setTimeout(performSearch, BASE_DELAY); }
function attachSearchHandler(){
if(!searchInput) return;
searchInput.removeEventListener('keyup', throttledHandler);
searchInput.removeEventListener('keyup', debouncedHandler);
if(searchModeSel && searchModeSel.value==='debounce'){
searchInput.addEventListener('keyup', debouncedHandler);
} else {
searchInput.addEventListener('keyup', throttledHandler);
}
}
if(searchModeSel){ searchModeSel.addEventListener('change', attachSearchHandler); }
attachSearchHandler();
function toggleRefreshBtn(dis){ var btn=document.getElementById('catalog-refresh-btn'); if(btn){ if(dis){ btn.setAttribute('disabled','disabled'); btn.setAttribute('aria-busy','true'); } else { btn.removeAttribute('disabled'); btn.removeAttribute('aria-busy'); } } }
function checkStatus(){
fetch('/themes/status',{cache:'no-store'}).then(r=>r.json()).then(js=>{
if(js.stale){
var ind=document.getElementById('theme-stale-indicator'); if(ind){ ind.style.display='inline'; }
toggleRefreshBtn(true);
fetch('/themes/refresh',{method:'POST'}).then(()=>{
var attempts=0; var max=20; var iv=setInterval(()=>{
fetch('/themes/status',{cache:'no-store'}).then(r=>r.json()).then(s2=>{
if(!s2.stale){ clearInterval(iv); if(ind) ind.style.display='none'; fetchList(); }
}).catch(()=>{});
attempts++; if(attempts>max){ clearInterval(iv); }
},1500);
}).finally(()=>{ toggleRefreshBtn(false); });
}
}).catch(()=>{});
}
function announceResultCount(){ var tbody=document.querySelector('#theme-results tbody'); if(!tbody) return; var count=tbody.querySelectorAll('tr.theme-row').length; var host=document.getElementById('theme-results'); if(host){ host.setAttribute('aria-label', count+' themes'); } }
window.addEventListener('load', checkStatus, {once:true});
})();
// Preview modal retry/backoff & logging
(function(){
document.addEventListener('click', function(e){ var btn=e.target.closest('button[data-preview-btn]'); if(!btn) return; var theme=btn.getAttribute('data-theme-id'); if(!theme) return; setTimeout(function(){ attach(theme); }, 40); });
function attach(theme){
var modal=document.getElementById('theme-preview-modal'); if(!modal) return;
var cacheKey='preview:'+theme;
var tStart = performance.now();
// Stale-While-Revalidate: show cached HTML immediately if present
try {
var cached=sessionStorage.getItem(cacheKey);
if(cached){ modal.innerHTML=cached; modal.setAttribute('data-swr','stale'); }
} catch(_e){}
var attempts=0,max=3,back=350; function run(){ attempts++; fetch('/themes/fragment/preview/'+theme,{cache:'no-store'}).then(function(r){ if(r.status===200) return r.text(); if(r.status===304) return ''; throw new Error('bad'); }).then(function(html){ if(!html) return; modal.innerHTML=html; structuredLog('preview_fetch_success'); try { sessionStorage.setItem(cacheKey, html); } catch(_e){} try { recordPreviewLatency(performance.now()-tStart); } catch(_e){} }).catch(function(){ structuredLog('preview_fetch_error'); try { recordPreviewLatency(performance.now()-tStart, true); } catch(_e){} if(attempts<max){ setTimeout(run, back); back*=2; } else { modal.innerHTML='<div class="preview-modal-content"><div role="alert" style="font-size:13px;">Failed to load preview.<br/><button id="retry-preview" class="btn">Retry</button></div></div>'; var rp=document.getElementById('retry-preview'); if(rp){ rp.addEventListener('click', function(){ attempts=0; back=350; run(); }); } } }); }
if(/Loading/.test(modal.textContent||'') || modal.getAttribute('data-swr')==='stale') run();
// Inject export buttons (single insertion guard)
setTimeout(function(){
try {
if(!modal.querySelector('.preview-export-bar') && modal.querySelector('.preview-header')){
var bar = document.createElement('div');
bar.className='preview-export-bar';
bar.style.cssText='margin:.5rem 0 .25rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; font-size:11px;';
bar.innerHTML = '<button class="btn btn-ghost" style="font-size:11px;padding:2px 8px;" data-exp-json>Export JSON</button>'+
'<button class="btn btn-ghost" style="font-size:11px;padding:2px 8px;" data-exp-csv>Export CSV</button>'+
'<span style="opacity:.6;">(Respects curated toggle)</span>';
var header = modal.querySelector('.preview-header');
header.parentNode.insertBefore(bar, header.nextSibling);
function curatedOnly(){ try { return localStorage.getItem('mtg:preview.curatedOnly')==='1'; } catch(_){ return false; } }
bar.querySelector('[data-exp-json]').addEventListener('click', function(){ window.open('/themes/preview/'+encodeURIComponent(theme)+'/export.json?curated_only='+(curatedOnly()?'1':'0'),'_blank'); });
bar.querySelector('[data-exp-csv]').addEventListener('click', function(){ window.open('/themes/preview/'+encodeURIComponent(theme)+'/export.csv?curated_only='+(curatedOnly()?'1':'0'),'_blank'); });
}
} catch(_e){}
}, 120);
}
})();
</script>
<style>
#theme-results.preload-hide { visibility:hidden; }
#catalog-refresh-btn[disabled]{ opacity:.55; cursor:progress; }
</style>
<script>
// Batch preview latency beacons every 20 events
(function(){
var latSamples=[]; var errSamples=0; var BATCH=20; window.recordPreviewLatency=function(ms, isErr){ try { latSamples.push(ms); if(isErr) errSamples++; if(latSamples.length>=BATCH && window.THEME_DIAG_ENABLED){ var avg=Math.round(latSamples.reduce((a,b)=>a+b,0)/latSamples.length); var payload={events:[{name:'preview_load_batch', count:latSamples.length, avg_ms:avg, err:errSamples}]}; if(navigator.sendBeacon){ navigator.sendBeacon('/themes/metrics/client', new Blob([JSON.stringify(payload)],{type:'application/json'})); } latSamples=[]; errSamples=0; } } catch(_e){} };
})();
</script>
{% endblock %}

View file

@ -0,0 +1,350 @@
{% if preview %}
<div class="preview-modal-content theme-preview-expanded{% if minimal %} minimal-variant{% endif %}">
{% if not minimal %}
<div class="preview-header" style="display:flex; justify-content:space-between; align-items:center; gap:1rem;">
<h3 style="margin:0; font-size:16px;" data-preview-heading>{{ preview.theme }}</h3>
<button id="preview-close-btn" onclick="document.getElementById('theme-preview-modal') && document.getElementById('theme-preview-modal').remove();" class="btn btn-ghost" style="font-size:12px; line-height:1;">Close ✕</button>
</div>
{% if preview.stub %}<div class="note note-stub">Stub sample (placeholder logic)</div>{% endif %}
<div class="preview-controls" style="display:flex; gap:1rem; align-items:center; margin:.5rem 0 .75rem; font-size:11px;">
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="reasons-toggle" checked/> Reasons <span style="opacity:.55; font-size:10px; cursor:help;" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
<span id="preview-status" aria-live="polite" style="opacity:.65;"></span>
</div>
<details id="preview-rationale" class="preview-rationale" style="margin:.25rem 0 .85rem; font-size:11px; background:var(--panel-alt); border:1px solid var(--border); padding:.55rem .7rem; border-radius:8px;">
<summary style="cursor:pointer; font-weight:600; letter-spacing:.05em;">Commander Overlap & Diversity Rationale</summary>
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:center; margin-top:.4rem;">
<button type="button" class="btn btn-ghost" style="font-size:10px; padding:4px 8px;" onclick="toggleHoverCompactMode()" title="Toggle compact hover panel (smaller image & condensed metadata)">Hover Compact</button>
<span id="hover-compact-indicator" style="font-size:10px; opacity:.7;">Mode: <span data-mode>normal</span></span>
</div>
<ul id="rationale-points" style="margin:.5rem 0 0 .9rem; padding:0; list-style:disc; line-height:1.35;">
<li>Computing…</li>
</ul>
</details>
{% endif %}
<div class="two-col" style="display:grid; grid-template-columns: 1fr 480px; gap:1.25rem; align-items:start; position:relative;" role="group" aria-label="Theme preview cards and commanders">
<div class="col-divider" style="position:absolute; top:0; bottom:0; left:calc(100% - 480px - .75rem); width:1px; background:var(--border); opacity:.55;"></div>
<div class="col-left">
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Cards</h4>{% else %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Sampled Synergy Cards</h4>{% endif %}{% endif %}
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
<div class="cards-flow" style="display:flex; flex-wrap:wrap; gap:10px;" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}">
{% set inserted = {'examples': False, 'curated_synergy': False, 'payoff': False, 'enabler_support': False, 'wildcard': False} %}
{% for c in preview.sample if (not suppress_curated and ('example' in c.roles or 'curated_synergy' in c.roles)) or 'payoff' in c.roles or 'enabler' in c.roles or 'support' in c.roles or 'wildcard' in c.roles %}
{% set primary = c.roles[0] if c.roles else '' %}
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.25rem;">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator" data-group="curated_synergy" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
{% if primary == 'payoff' and not inserted.payoff %}<div class="group-separator" data-group="payoff" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Payoffs</div>{% set _ = inserted.update({'payoff': True}) %}{% endif %}
{% if primary in ['enabler','support'] and not inserted.enabler_support %}<div class="group-separator" data-group="enabler_support" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Enablers & Support</div>{% set _ = inserted.update({'enabler_support': True}) %}{% endif %}
{% if primary == 'wildcard' and not inserted.wildcard %}<div class="group-separator" data-group="wildcard" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Wildcards</div>{% set _ = inserted.update({'wildcard': True}) %}{% endif %}
{% set overlaps = [] %}
{% if preview.synergies_used and c.tags %}
{% for tg in c.tags %}{% if tg in preview.synergies_used %}{% set _ = overlaps.append(tg) %}{% endif %}{% endfor %}
{% endif %}
<div class="card-sample{% if overlaps %} has-overlap{% endif %}" style="width:230px;" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}">
<div class="thumb-wrap" style="position:relative;">
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
<span class="role-chip role-{{ c.roles[0] if c.roles }}" title="Primary role: {{ c.roles[0] if c.roles }}">{{ c.roles[0][0]|upper if c.roles }}</span>
{% if overlaps %}<span class="overlap-badge" title="Synergy overlaps: {{ overlaps|join(', ') }}">{{ overlaps|length }}</span>{% endif %}
</div>
<div class="meta" style="font-size:12px; margin-top:2px;">
<div class="ci-ribbon" aria-label="Color identity" style="display:flex; gap:2px; margin-bottom:2px; min-height:10px;"></div>
<div class="nm" style="font-weight:600; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ c.name }}">{{ c.name }}</div>
<div class="mana-line" aria-label="Mana Cost" style="min-height:14px; display:flex; flex-wrap:wrap; gap:2px; font-size:10px;"></div>
{% if c.rarity %}<div class="rarity-badge rarity-{{ c.rarity }}" title="Rarity: {{ c.rarity }}" style="font-size:9px; letter-spacing:.5px; text-transform:uppercase; opacity:.7;">{{ c.rarity }}</div>{% endif %}
<div class="role" style="opacity:.75; font-size:11px; display:flex; flex-wrap:wrap; gap:3px;">
{% for r in c.roles %}<span class="mini-badge role-{{ r }}" title="{{ r }} role">{{ r[0]|upper }}</span>{% endfor %}
</div>
{% if c.reasons %}<div class="reasons" data-reasons-block style="font-size:9px; opacity:.55; line-height:1.15;" title="Heuristics: {{ c.reasons|join(', ') }}">{{ c.reasons|map('replace','commander_bias','cmbias')|join(' · ') }}</div>{% endif %}
</div>
</div>
{% endfor %}
{% set has_synth = false %}
{% for c in preview.sample %}{% if 'synthetic' in c.roles %}{% set has_synth = true %}{% endif %}{% endfor %}
{% if has_synth %}
<div style="flex-basis:100%; height:0;"></div>
{% for c in preview.sample %}
{% if 'synthetic' in c.roles %}
<div class="card-sample synthetic" style="width:230px; border:1px dashed var(--border); padding:8px; border-radius:10px; background:var(--panel-alt);" data-card-name="{{ c.name }}" data-role="synthetic" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="">
<div style="font-size:12px; font-weight:600; line-height:1.2;">{{ c.name }}</div>
<div style="font-size:11px; opacity:.8;">{{ c.roles|join(', ') }}</div>
{% if c.reasons %}<div style="font-size:10px; margin-top:2px; opacity:.6; line-height:1.15;">{{ c.reasons|join(', ') }}</div>{% endif %}
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
<div class="col-right">
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Commanders</h4>{% else %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Synergy Commanders</h4>{% endif %}{% endif %}
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
{% if example_commanders and not suppress_curated %}
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
{% for name in example_commanders %}
{# Derive per-commander overlaps; still show full theme synergy set in data-tags for context #}
{% set base = name %}
{% set overlaps = [] %}
{% if ' - Synergy (' in name %}
{% set base = name.split(' - Synergy (')[0] %}
{% set annot = name.split(' - Synergy (')[1].rstrip(')') %}
{% for sy in annot.split(',') %}{% set _ = overlaps.append(sy.strip()) %}{% endfor %}
{% endif %}
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
<div class="commander-cell" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
<div class="commander-name" style="font-size:13px; text-align:center; line-height:1.35; font-weight:600; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
</div>
{% endfor %}
</div>
{% elif not suppress_curated %}
<div style="font-size:11px; opacity:.7;">No curated commander examples.</div>
{% endif %}
{% if synergy_commanders %}
<div style="margin-top:1rem;">
<div style="display:flex; align-items:center; gap:.4rem; margin-bottom:.4rem;">
<h5 style="margin:0; font-size:11px; letter-spacing:.05em; text-transform:uppercase; opacity:.75;">Synergy Commanders</h5>
<span title="Derived from synergy overlap heuristics" style="background:var(--panel-alt); border:1px solid var(--border); border-radius:10px; padding:2px 6px; font-size:10px; line-height:1;">Derived</span>
</div>
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
{% for name in synergy_commanders[:8] %}
{# Strip any appended ' - Synergy (...' suffix for image lookup while preserving display #}
{% set base = name %}
{% if ' - Synergy' in name %}{% set base = name.split(' - Synergy')[0] %}{% endif %}
{% set overlaps = [] %}
{% if ' - Synergy (' in name %}
{% set annot = name.split(' - Synergy (')[1].rstrip(')') %}
{% for sy in annot.split(',') %}{% set _ = overlaps.append(sy.strip()) %}{% endfor %}
{% endif %}
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
<div class="commander-cell synergy" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
<div class="commander-name" style="font-size:12px; text-align:center; line-height:1.3; font-weight:500; opacity:.92; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% if not minimal %}<div style="margin-top:1rem; font-size:10px; opacity:.65; line-height:1.4;">Hover any card or commander for a larger preview and tag breakdown. Use Curated Only to hide sampled roles. Role chips: P=Payoff, E=Enabler, S=Support, W=Wildcard, X=Curated Example.</div>{% endif %}
</div>
{% else %}
<div class="preview-modal-content">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="sk-bar" style="height:16px; width:200px; background:var(--hover); border-radius:4px;"></div>
<div class="sk-bar" style="height:16px; width:60px; background:var(--hover); border-radius:4px;"></div>
</div>
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-top:1rem;">
{% for i in range(8) %}<div style="width:230px; height:327px; background:var(--hover); border-radius:10px;"></div>{% endfor %}
</div>
</div>
{% endif %}
<style>
.theme-preview-expanded .card-thumb { width:230px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
.theme-preview-expanded .role-chip { position:absolute; top:4px; left:4px; background:rgba(0,0,0,0.65); color:#fff; font-size:10px; padding:2px 5px; border-radius:10px; line-height:1; letter-spacing:.5px; }
.theme-preview-expanded .mini-badge { background:var(--panel-alt); border:1px solid var(--border); padding:1px 4px; font-size:9px; border-radius:8px; line-height:1; }
.theme-preview-expanded .role-payoff .role-chip, .mini-badge.role-payoff { background:#2563eb; color:#fff; }
.mini-badge.role-payoff { background:#1d4ed8; color:#fff; }
.mini-badge.role-enabler { background:#047857; color:#fff; }
.mini-badge.role-support { background:#6d28d9; color:#fff; }
.mini-badge.role-wildcard { background:#92400e; color:#fff; }
.mini-badge.role-example, .mini-badge.role-curated_synergy { background:#4f46e5; color:#fff; }
.theme-preview-expanded .commander-grid .card-thumb { width:230px; }
.theme-preview-expanded.minimal-variant .preview-header,
.theme-preview-expanded.minimal-variant .preview-controls,
.theme-preview-expanded.minimal-variant .preview-rationale { display:none !important; }
.theme-preview-expanded.minimal-variant h4 { display:none; }
.theme-preview-expanded .commander-cell.synergy .card-thumb { filter:grayscale(.15) contrast(1.05); }
.theme-preview-expanded .card-sample.synthetic { display:flex; flex-direction:column; justify-content:flex-start; }
.theme-preview-expanded .card-sample.has-overlap { outline:1px solid var(--accent); outline-offset:2px; }
/* Hover panel parity styling */
#hover-card-panel { font-family: inherit; }
#hover-card-panel .hcp-role { display:inline-block; margin-left:6px; padding:2px 6px; font-size:10px; letter-spacing:.5px; border:1px solid var(--border); border-radius:10px; background:var(--panel-alt); text-transform:uppercase; }
#hover-card-panel.is-payoff .hcp-role { background:var(--accent, #38bdf8); color:#fff; border-color:var(--accent, #38bdf8); }
#hover-card-panel .hcp-reasons li { margin:2px 0; }
#hover-card-panel .hcp-reasons { scrollbar-width:thin; }
#hover-card-panel .hcp-tags { font-size:10px; opacity:.75; }
.theme-preview-expanded .overlap-badge { position:absolute; top:4px; right:4px; background:#0f766e; color:#fff; font-size:10px; padding:2px 5px; border-radius:10px; }
.theme-preview-expanded .mana-symbol { width:14px; height:14px; border-radius:50%; background:#222; color:#fff; display:inline-flex; align-items:center; justify-content:center; font-size:9px; font-weight:600; box-shadow:0 0 0 1px #000 inset; }
.theme-preview-expanded .mana-symbol.W { background:#f3f2dc; color:#222; }
.theme-preview-expanded .mana-symbol.U { background:#5b8dd6; }
.theme-preview-expanded .mana-symbol.B { background:#2d2d2d; }
.theme-preview-expanded .mana-symbol.R { background:#c4472d; }
.theme-preview-expanded .mana-symbol.G { background:#2f6b3a; }
.theme-preview-expanded .mana-symbol.C { background:#555; }
.theme-preview-expanded .ci-ribbon .pip { width:10px; height:10px; border-radius:50%; display:inline-block; box-shadow:0 0 0 1px #000 inset; }
.theme-preview-expanded .ci-ribbon .pip.W { background:#f3f2dc; }
.theme-preview-expanded .ci-ribbon .pip.U { background:#5b8dd6; }
.theme-preview-expanded .ci-ribbon .pip.B { background:#2d2d2d; }
.theme-preview-expanded .ci-ribbon .pip.R { background:#c4472d; }
.theme-preview-expanded .ci-ribbon .pip.G { background:#2f6b3a; }
.theme-preview-expanded .tooltip-reasons ul { margin:0; padding-left:14px; }
.theme-preview-expanded .tooltip-reasons li { list-style:disc; margin:0; padding:0; }
.theme-preview-expanded .rarity-common { color:#9ca3af; }
.theme-preview-expanded .rarity-uncommon { color:#60a5fa; }
.theme-preview-expanded .rarity-rare { color:#fbbf24; }
.theme-preview-expanded .rarity-mythic { color:#fb923c; }
@media (max-width: 950px){ .theme-preview-expanded .two-col { grid-template-columns: 1fr; } .theme-preview-expanded .col-right { order:-1; } }
</style>
<script>
// sessionStorage preview fragment cache (keyed by theme + limit + commander). Stores HTML + ETag.
(function(){ if(document.querySelector('.theme-preview-expanded.minimal-variant')) return;
try {
var root = document.getElementById('theme-preview-modal');
if(!root) return;
var container = root.querySelector('.preview-modal-content');
if(!container) return;
// Attach a marker for quick retrieval
container.setAttribute('data-preview-fragment','1');
} catch(_){}
})();
</script>
<script>
// Lazy-load fallback for browsers ignoring loading=lazy (very old) + intersection observer prefetch enhancement
(function(){
try {
if('loading' in HTMLImageElement.prototype) return; // native supported
var imgs = Array.prototype.slice.call(document.querySelectorAll('.theme-preview-expanded img[loading="lazy"]'));
imgs.forEach(function(img){
if(!img.dataset.src){ img.dataset.src = img.src; }
img.src = img.dataset.src;
});
} catch(_){}
})();
</script>
<script>
// Lightweight hover tooltip for card reasons (progressive enhancement)
(function(){
var host = document.currentScript && document.currentScript.parentElement;
if(!host) return;
var tip = document.createElement('div');
tip.className='tooltip-reasons';
tip.style.position='fixed'; tip.style.pointerEvents='none'; tip.style.zIndex=9500; tip.style.padding='6px 8px'; tip.style.fontSize='11px'; tip.style.background='rgba(0,0,0,0.8)'; tip.style.color='#fff'; tip.style.border='1px solid var(--border)'; tip.style.borderRadius='6px'; tip.style.boxShadow='0 2px 8px rgba(0,0,0,0.4)'; tip.style.display='none'; maxWidth='260px';
document.body.appendChild(tip);
function show(e, html){ tip.innerHTML = html; tip.style.display='block'; move(e); }
function move(e){ tip.style.top=(e.clientY+14)+'px'; tip.style.left=(e.clientX+12)+'px'; }
function hide(){ tip.style.display='none'; }
host.addEventListener('mouseover', function(ev){
if(ev.target.closest('.thumb-wrap')) return;
var t = ev.target.closest('.card-sample');
if(!t) return;
var name = t.querySelector('.nm') ? t.querySelector('.nm').textContent : t.getAttribute('data-card-name');
var role = t.getAttribute('data-role');
var reasons = t.getAttribute('data-reasons') || '';
var tags = t.getAttribute('data-tags') || '';
var overlaps = t.getAttribute('data-overlaps') || '';
var html = '<strong>'+ (name||'') +'</strong><br/><em>'+ (role||'') +'</em>';
if(tags){
if(overlaps){
var tagArr = tags.split(/\s*,\s*/);
var overlapSet = new Set(overlaps.split(/\s*,\s*/).filter(Boolean));
var rendered = tagArr.map(function(x){ return overlapSet.has(x) ? '<span style="color:#0ea5e9; font-weight:600;">'+x+'</span>' : x; }).join(', ');
html += '<br/><span style="opacity:.85">'+ rendered +'</span>';
} else {
html += '<br/><span style="opacity:.8">'+tags+'</span>';
}
}
if(reasons){
var items = reasons.split(/;\s*/).filter(Boolean).map(function(r){ return '<li>'+r+'</li>'; }).join('');
html += '<div style="margin-top:4px; font-size:10px; line-height:1.25;"><ul>'+items+'</ul></div>';
}
show(ev, html);
});
host.addEventListener('mousemove', function(ev){ if(tip.style.display==='block') move(ev); });
host.addEventListener('mouseleave', function(ev){ if(!ev.relatedTarget || !ev.relatedTarget.closest('.card-sample')) hide(); }, true);
host.addEventListener('mouseout', function(ev){ if(!ev.relatedTarget || !ev.relatedTarget.closest('.card-sample')) hide(); });
})();
</script>
<script>
// Post-render safety pass: normalize commander thumbnails.
// 1. If annotated form 'Name - Synergy (A, B)' still in data-card-name, strip to base.
// 2. If annotation present in original name but data-tags/data-overlaps empty, populate them.
(function(){
try {
document.querySelectorAll('.theme-preview-expanded img.card-thumb').forEach(function(img){
var n = img.getAttribute('data-card-name') || '';
var orig = img.getAttribute('data-original-name') || n;
// Patterns to strip: ' - Synergy (' plus any trailing text/paren and optional closing paren
var m = /(.*?)(\s*-\s*Synergy\b.*)$/i.exec(orig);
if(m){
var base = m[1].trim();
if(base && base !== n){
img.setAttribute('data-card-name', base);
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
}
// Attempt to derive overlaps if not already present
if(!img.getAttribute('data-overlaps')){
var annMatch = /-\s*Synergy\s*\(([^)]+)\)/i.exec(orig);
if(annMatch){
var list = annMatch[1].split(',').map(function(x){return x.trim();}).filter(Boolean).join(', ');
if(list){
// Preserve existing broader data-tags if present; only set overlaps
if(!img.getAttribute('data-tags')) img.setAttribute('data-tags', list);
img.setAttribute('data-overlaps', list);
}
}
}
}
});
} catch(_){ }
})();
</script>
<script>
// Mana cost parser to convert {X}{2}{U}{B/P} style strings into colored symbol bubbles.
// Removed legacy client-side mana parser (server now supplies normalized mana & pip/color metadata)
// Placeholder: if server later supplies pre-rendered HTML we simply inject it here.
// (Intentionally no-op; roadmap EXIT Server-side mana/rarity ingestion follow-up.)
(()=>{})();
</script>
<script>
// Color identity ribbon (simple heuristic from mana cost symbols); shown above name.
// Removed heuristic color identity derivation (server now provides authoritative color_identity_list)
// Future: server can inline <span class="pip W"></span> elements directly; leaving ribbon container empty if absent.
(()=>{})();
</script>
<script>
// (Removed fragment-specific large hover panel; using global unified panel in base.html)
</script>
<script>
// Commander overlap & diversity rationale (client-side derivation) Phase 1 tooltip implementation
(function(){
try {
var listHost = document.getElementById('rationale-points');
if(!listHost) return;
var modeLabel = document.querySelector('#hover-compact-indicator [data-mode]');
document.addEventListener('mtg:hoverCompactToggle', function(){ if(modeLabel){ modeLabel.textContent = window.__hoverCompactMode ? 'compact' : 'normal'; }});
var cards = Array.from(document.querySelectorAll('.theme-preview-expanded .card-sample'))
.filter(c=>!(c.classList.contains('synthetic')));
if(!cards.length){ listHost.innerHTML='<li>No real cards in sample.</li>'; return; }
var roleCounts = {payoff:0,enabler:0,support:0,wildcard:0,example:0,curated_synergy:0,synthetic:0};
var overlapTotals = 0; var overlapSet = new Set();
cards.forEach(c=>{
var role = c.getAttribute('data-role')||'';
if(roleCounts[role]!==undefined) roleCounts[role]++;
var overlaps = (c.getAttribute('data-overlaps')||'').split(/\s*,\s*/).filter(Boolean);
overlaps.forEach(o=>overlapSet.add(o));
overlapTotals += overlaps.length;
});
var totalReal = cards.length;
function pct(n){ return (n/totalReal*100).toFixed(1)+'%'; }
var diversityScore = 0;
var coreRoles = ['payoff','enabler','support','wildcard'];
var ideal = {payoff:0.4,enabler:0.2,support:0.2,wildcard:0.2};
coreRoles.forEach(r=>{ var actual = roleCounts[r]/Math.max(1,totalReal); diversityScore += (1 - Math.abs(actual - ideal[r])); });
diversityScore = (diversityScore / coreRoles.length * 100).toFixed(1);
var avgOverlap = (overlapTotals / Math.max(1,totalReal)).toFixed(2);
var points = [];
points.push('Roles mix: '+coreRoles.map(r=>r[0].toUpperCase()+r.slice(1)+"="+roleCounts[r]+' ('+pct(roleCounts[r])+')').join(', '));
points.push('Distinct synergy overlaps represented: '+overlapSet.size);
points.push('Average synergy overlaps per card: '+avgOverlap);
points.push('Diversity heuristic score: '+diversityScore);
var curated = roleCounts.example + roleCounts.curated_synergy;
points.push('Curated cards: '+curated+' ('+pct(curated)+')');
// Placeholder future richer analytics (P2 roadmap): spread index, top synergy concentration
var spreadIndex = (overlapSet.size / Math.max(1, (cards.length))).toFixed(2);
points.push('Synergy spread index: '+spreadIndex);
listHost.innerHTML = points.map(p=>'<'+'li>'+p+'</li>').join('');
} catch(e){ /* silent */ }
})();
</script>

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ services:
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_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
# ------------------------------------------------------------------
# Random Build (Alpha) Feature Flags

View file

@ -21,6 +21,7 @@ services:
ENABLE_PRESETS: "0" # 1=show presets section
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # Include/Exclude feature enable
WEB_THEME_PICKER_DIAGNOSTICS: "0" # 1=enable extra theme catalog diagnostics fields, uncapped synergies & /themes/metrics
# ------------------------------------------------------------------
# Random Build (Alpha) Feature Flags

View file

@ -0,0 +1,65 @@
# Theme Taxonomy Rationale & Governance
This document captures decision criteria and rationale for expanding, merging, or refining the theme taxonomy.
## Goals
- Maintain meaningful, player-recognizable buckets.
- Avoid overspecialization (micro-themes) that dilute search & filtering.
- Preserve sampling diversity and editorial sustainability.
## Expansion Checklist
A proposed new theme SHOULD satisfy ALL of:
1. Distinct Strategic Identity: The game plan (win condition / resource axis) is not already adequately described by an existing theme or combination of two existing themes.
2. Representative Card Depth: At least 8 broadly played, format-relevant cards (EDHREC / common play knowledge) naturally cluster under this identity.
3. Commander Support: At least 3 reasonable commander candidates (not including fringe silver-bullets) benefit from or enable the theme.
4. Non-Subset Test: The candidate is not a strict subset of an existing theme's synergy list (check overlap ≥70% == probable subset).
5. Editorial Coverage Plan: Concrete initial examples & synergy tags identified; no reliance on placeholders at introduction.
If any criterion fails -> treat as a synergy tag inside an existing theme rather than a standalone theme.
## Candidate Themes & Notes
| Candidate | Rationale | Risks / Watchouts | Initial Verdict |
|-----------|-----------|-------------------|-----------------|
| Combo | High-synergy deterministic or infinite loops. Already partly surfaced via combo detection features. | Over-broad; could absorb unrelated value engines. | Defer; emphasize combo detection tooling instead. |
| Storm | Spell-chain count scaling (Grapeshot, Tendrils). Distinct engine requiring density/rituals. | Low breadth in casual metas; may overlap with Spellslinger. | Accept (pending 8-card list + commander examples). |
| Extra Turns | Time Walk recursion cluster. | Potential negative play perception; governance needed to avoid glorifying NPE lines. | Tentative accept (tag only until list curated). |
| Group Hug / Politics | Resource gifting & table manipulation. | Hard to score objectively; card set is broad. | Accept with curated examples to anchor definition. |
| Pillowfort | Defensive taxation / attack deterrence (Ghostly Prison line). | Overlap with Control / Enchantments. | Accept; ensure non-redundant with generic Enchantments. |
| Toolbox / Tutors | Broad search utility enabling silver-bullet packages. | Tutors already subject to bracket policy thresholds; broad risk. | Defer; retain as synergy tag only. |
| Treasure Matters | Explicit treasure scaling (Academy Manufactor, Prosper). | Rapidly evolving; needs periodic review. | Accept. |
| Monarch / Initiative | Alternate advantage engines via emblems/dungeons. | Initiative narrower post-rotation; watch meta shifts. | Accept (merge both into a single theme for now). |
## Merge / Normalization Guidelines
When overlap (Jaccard) between Theme A and Theme B > 0.55 across curated+enforced synergies OR example card intersection ≥60%, evaluate for merge. Preference order:
1. Retain broader, clearer name.
2. Preserve curated examples; move excess to synergy tags.
3. Add legacy name to `aliases` for backward compatibility.
## Example Count Enforcement
Threshold flips to hard enforcement after global coverage >90%:
- Missing required examples -> linter error (`lint_theme_editorial.py --require-examples`).
- Build fails CI unless waived with explicit override label.
## Splash Relax Policy Rationale
- Prevents 45 color commanders from feeling artificially constrained when one enabling piece lies just outside colors.
- Controlled by single-card allowance + -0.3 score penalty so off-color never outranks true color-aligned payoffs.
## Popularity Buckets Non-Scoring Principle
Popularity reflects observational frequency and is intentionally orthogonal to sampling to avoid feedback loops. Any future proposal to weight by popularity must include a diversity impact analysis and opt-in feature flag.
## Determinism & Reproducibility
All sampling randomness is derived from `seed = hash(theme|commander)`; taxonomy updates must document any score function changes in `CHANGELOG.md` and provide transition notes if output ordering shifts beyond acceptable tolerance.
## Governance Change Process
1. Open a PR modifying taxonomy YAML or this file.
2. Include: rationale, representative card list, commander list, overlap analysis with nearest themes.
3. Run catalog build + linter; attach metrics snapshot (`preview_metrics_snapshot.py`).
4. Reviewer checks duplication, size, overlap, enforcement thresholds.
## Future Considerations
- Automated overlap dashboard (heatmap) for candidate merges.
- Nightly diff bot summarizing coverage & generic description regression.
- Multi-dimensional rarity quota experimentation (moved to Deferred section for now).
---
Last updated: 2025-09-20