mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
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:
parent
8f47dfbb81
commit
c4a7fc48ea
40 changed files with 6092 additions and 17312 deletions
|
|
@ -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)
|
||||
|
|
|
|||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -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 sub‑50ms 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).
|
||||
- In‑memory 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 4–5 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
BIN
README.md
Binary file not shown.
|
|
@ -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 sub‑50ms 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.
|
||||
- In‑memory 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
105
code/scripts/preview_metrics_snapshot.py
Normal file
105
code/scripts/preview_metrics_snapshot.py
Normal 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:]))
|
||||
100
code/scripts/validate_theme_fast_path.py
Normal file
100
code/scripts/validate_theme_fast_path.py
Normal 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:]))
|
||||
91
code/scripts/warm_preview_traffic.py
Normal file
91
code/scripts/warm_preview_traffic.py
Normal 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:]))
|
||||
30
code/tests/test_fast_theme_list_regression.py
Normal file
30
code/tests/test_fast_theme_list_regression.py
Normal 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']
|
||||
|
||||
20
code/tests/test_preview_curated_examples_regression.py
Normal file
20
code/tests/test_preview_curated_examples_regression.py
Normal 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]}"
|
||||
22
code/tests/test_preview_error_rate_metrics.py
Normal file
22
code/tests/test_preview_error_rate_metrics.py
Normal 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
|
||||
35
code/tests/test_preview_metrics_percentiles.py
Normal file
35
code/tests/test_preview_metrics_percentiles.py
Normal 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))
|
||||
13
code/tests/test_preview_minimal_variant.py
Normal file
13
code/tests/test_preview_minimal_variant.py
Normal 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
|
||||
17
code/tests/test_preview_suppress_curated_flag.py
Normal file
17
code/tests/test_preview_suppress_curated_flag.py
Normal 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
|
||||
204
code/tests/test_theme_api_phase_e.py
Normal file
204
code/tests/test_theme_api_phase_e.py
Normal 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
|
||||
247
code/tests/test_theme_picker_gaps.py
Normal file
247
code/tests/test_theme_picker_gaps.py
Normal 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"
|
||||
62
code/tests/test_theme_preview_additional.py
Normal file
62
code/tests/test_theme_preview_additional.py
Normal 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'
|
||||
38
code/tests/test_theme_preview_ordering.py
Normal file
38
code/tests/test_theme_preview_ordering.py
Normal 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"
|
||||
72
code/tests/test_theme_preview_p0_new.py
Normal file
72
code/tests/test_theme_preview_p0_new.py
Normal 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})"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
30
code/web/models/theme_api.py
Normal file
30
code/web/models/theme_api.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
511
code/web/services/theme_catalog_loader.py
Normal file
511
code/web/services/theme_catalog_loader.py
Normal 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
|
||||
862
code/web/services/theme_preview.py
Normal file
862
code/web/services/theme_preview.py
Normal 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.25–0.55: gently nudge toward base ( +/- 30s toward _TTL_BASE )
|
||||
- If hit ratio between 0.55–0.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
|
||||
|
|
@ -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,'<') + '</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,'<') + '</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,'<'); 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,'<')+'</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;"> </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;"> </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>
|
||||
|
|
|
|||
|
|
@ -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);">
|
||||
|
|
|
|||
121
code/web/templates/themes/catalog_simple.html
Normal file
121
code/web/templates/themes/catalog_simple.html
Normal 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 %}
|
||||
84
code/web/templates/themes/detail_fragment.html
Normal file
84
code/web/templates/themes/detail_fragment.html
Normal 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>
|
||||
4
code/web/templates/themes/detail_page.html
Normal file
4
code/web/templates/themes/detail_page.html
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% include 'themes/detail_fragment.html' %}
|
||||
{% endblock %}
|
||||
155
code/web/templates/themes/list_fragment.html
Normal file
155
code/web/templates/themes/list_fragment.html
Normal 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 %}
|
||||
44
code/web/templates/themes/list_simple_fragment.html
Normal file
44
code/web/templates/themes/list_simple_fragment.html
Normal 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>
|
||||
403
code/web/templates/themes/picker.html
Normal file
403
code/web/templates/themes/picker.html
Normal 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 %}
|
||||
350
code/web/templates/themes/preview_fragment.html
Normal file
350
code/web/templates/themes/preview_fragment.html
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
docs/theme_taxonomy_rationale.md
Normal file
65
docs/theme_taxonomy_rationale.md
Normal 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 4–5 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue