feat(editorial): Phase D synergy commander enrichment, augmentation, lint & docs\n\nAdds Phase D editorial tooling: synergy-based commander selection with 3/2/1 pattern, duplicate filtering, annotated synergy_commanders, promotion to minimum examples, and augmentation heuristics (e.g. Counters Matter/Proliferate injection). Includes new scripts (generate_theme_editorial_suggestions, lint, validate, catalog build/apply), updates orchestrator & web routes, expands CI workflow, and documents usage & non-determinism policies. Updates lint rules, type definitions, and docker configs.

This commit is contained in:
matt 2025-09-18 10:59:20 -07:00
parent 16261bbf09
commit f2a76d2ffc
35 changed files with 2818 additions and 509 deletions

View file

@ -78,7 +78,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
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)
RANDOM_MODES = _as_bool(os.getenv("RANDOM_MODES"), False) # initial snapshot (legacy)
RANDOM_UI = _as_bool(os.getenv("RANDOM_UI"), False)
def _as_int(val: str | None, default: int) -> int:
try:
@ -200,11 +200,17 @@ async def status_sys():
except Exception:
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
def random_modes_enabled() -> bool:
"""Dynamic check so tests that set env after import still work.
Keeps legacy global for template snapshot while allowing runtime override."""
return _as_bool(os.getenv("RANDOM_MODES"), bool(RANDOM_MODES))
# --- Random Modes API ---
@app.post("/api/random_build")
async def api_random_build(request: Request):
# Gate behind feature flag
if not RANDOM_MODES:
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
body = {}
@ -253,7 +259,7 @@ async def api_random_build(request: Request):
@app.post("/api/random_full_build")
async def api_random_full_build(request: Request):
# Gate behind feature flag
if not RANDOM_MODES:
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
body = {}
@ -324,7 +330,7 @@ async def api_random_full_build(request: Request):
@app.post("/api/random_reroll")
async def api_random_reroll(request: Request):
# Gate behind feature flag
if not RANDOM_MODES:
if not random_modes_enabled():
raise HTTPException(status_code=404, detail="Random Modes disabled")
try:
body = {}
@ -532,11 +538,13 @@ from .routes import configs as config_routes # noqa: E402
from .routes import decks as decks_routes # noqa: E402
from .routes import setup as setup_routes # noqa: E402
from .routes import owned as owned_routes # noqa: E402
from .routes import themes as themes_routes # noqa: E402
app.include_router(build_routes.router)
app.include_router(config_routes.router)
app.include_router(decks_routes.router)
app.include_router(setup_routes.router)
app.include_router(owned_routes.router)
app.include_router(themes_routes.router)
# Warm validation cache early to reduce first-call latency in tests and dev
try:

View file

@ -1355,7 +1355,7 @@ async def build_combos_panel(request: Request) -> HTMLResponse:
weights = {
"treasure": 3.0, "tokens": 2.8, "landfall": 2.6, "card draw": 2.5, "ramp": 2.3,
"engine": 2.2, "value": 2.1, "artifacts": 2.0, "enchantress": 2.0, "spellslinger": 1.9,
"counters": 1.8, "equipment": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
"counters": 1.8, "equipment matters": 1.7, "tribal": 1.6, "lifegain": 1.5, "mill": 1.4,
"damage": 1.3, "stax": 1.2
}
syn_sugs: list[dict] = []

View file

@ -14,11 +14,19 @@ router = APIRouter(prefix="/setup")
def _kickoff_setup_async(force: bool = False):
"""Start setup/tagging in a background thread.
Previously we passed a no-op output function, which hid downstream steps (e.g., theme export).
Using print provides visibility in container logs and helps diagnose export issues.
"""
def runner():
try:
_ensure_setup_ready(lambda _m: None, force=force) # type: ignore[arg-type]
except Exception:
pass
_ensure_setup_ready(print, force=force) # type: ignore[arg-type]
except Exception as e: # pragma: no cover - background best effort
try:
print(f"Setup thread failed: {e}")
except Exception:
pass
t = threading.Thread(target=runner, daemon=True)
t.start()

126
code/web/routes/themes.py Normal file
View file

@ -0,0 +1,126 @@
from __future__ import annotations
import json
from datetime import datetime as _dt
from pathlib import Path
from typing import Optional, Dict, Any
from fastapi import APIRouter
from fastapi import BackgroundTasks
from ..services.orchestrator import _ensure_setup_ready # type: ignore
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/themes", tags=["themes"]) # /themes/status
THEME_LIST_PATH = Path("config/themes/theme_list.json")
CATALOG_DIR = Path("config/themes/catalog")
STATUS_PATH = Path("csv_files/.setup_status.json")
TAG_FLAG_PATH = Path("csv_files/.tagging_complete.json")
def _iso(ts: float | int | None) -> Optional[str]:
if ts is None or ts <= 0:
return None
try:
return _dt.fromtimestamp(ts).isoformat(timespec="seconds")
except Exception:
return None
def _load_status() -> Dict[str, Any]:
try:
if STATUS_PATH.exists():
return json.loads(STATUS_PATH.read_text(encoding="utf-8") or "{}") or {}
except Exception:
pass
return {}
def _load_tag_flag_time() -> Optional[float]:
try:
if TAG_FLAG_PATH.exists():
data = json.loads(TAG_FLAG_PATH.read_text(encoding="utf-8") or "{}") or {}
t = data.get("tagged_at")
if isinstance(t, str) and t.strip():
try:
return _dt.fromisoformat(t.strip()).timestamp()
except Exception:
return None
except Exception:
return None
return None
@router.get("/status")
async def theme_status():
"""Return current theme export status for the UI.
Provides counts, mtimes, and freshness vs. tagging flag.
"""
try:
status = _load_status()
theme_list_exists = THEME_LIST_PATH.exists()
theme_list_mtime_s = THEME_LIST_PATH.stat().st_mtime if theme_list_exists else None
theme_count: Optional[int] = None
parse_error: Optional[str] = None
if theme_list_exists:
try:
raw = json.loads(THEME_LIST_PATH.read_text(encoding="utf-8") or "{}") or {}
if isinstance(raw, dict):
themes = raw.get("themes")
if isinstance(themes, list):
theme_count = len(themes)
except Exception as e: # pragma: no cover
parse_error = f"parse_error: {e}" # keep short
yaml_catalog_exists = CATALOG_DIR.exists() and CATALOG_DIR.is_dir()
yaml_file_count = 0
if yaml_catalog_exists:
try:
yaml_file_count = len([p for p in CATALOG_DIR.iterdir() if p.suffix == ".yml"]) # type: ignore[arg-type]
except Exception:
yaml_file_count = -1
tagged_time = _load_tag_flag_time()
stale = False
if tagged_time and theme_list_mtime_s:
# Stale if tagging flag is newer by > 1 second
stale = tagged_time > (theme_list_mtime_s + 1)
# Also stale if we expect a catalog (after any tagging) but have suspiciously few YAMLs (< 100)
if yaml_catalog_exists and yaml_file_count >= 0 and yaml_file_count < 100:
stale = True
last_export_at = status.get("themes_last_export_at") or _iso(theme_list_mtime_s) or None
resp = {
"ok": True,
"theme_list_exists": theme_list_exists,
"theme_list_mtime": _iso(theme_list_mtime_s),
"theme_count": theme_count,
"yaml_catalog_exists": yaml_catalog_exists,
"yaml_file_count": yaml_file_count,
"stale": stale,
"last_export_at": last_export_at,
"last_export_fast_path": status.get("themes_last_export_fast_path"),
"phase": status.get("phase"),
"running": status.get("running"),
}
if parse_error:
resp["parse_error"] = parse_error
return JSONResponse(resp)
except Exception as e: # pragma: no cover
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.post("/refresh")
async def theme_refresh(background: BackgroundTasks):
"""Force a theme export refresh without re-tagging if not needed.
Runs setup readiness with force=False (fast-path export fallback will run). Returns immediately.
"""
try:
def _runner():
try:
_ensure_setup_ready(lambda _m: None, force=False) # export fallback triggers
except Exception:
pass
background.add_task(_runner)
return JSONResponse({"ok": True, "started": True})
except Exception as e: # pragma: no cover
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)

View file

@ -732,6 +732,8 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
Mirrors the CLI behavior used in build_deck_full: if csv_files/cards.csv is
missing, too old, or the tagging flag is absent, run initial setup and tagging.
"""
# Track whether a theme catalog export actually executed during this invocation
theme_export_performed = False
def _write_status(payload: dict) -> None:
try:
os.makedirs('csv_files', exist_ok=True)
@ -754,6 +756,138 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
except Exception:
pass
def _refresh_theme_catalog(out_func, *, force: bool, fast_path: bool = False) -> None:
"""Generate or refresh theme JSON + per-theme YAML exports.
force: when True pass --force to YAML exporter (used right after tagging).
fast_path: when True indicates we are refreshing without a new tagging run.
"""
try: # Broad defensive guard: never let theme export kill setup flow
phase_label = 'themes-fast' if fast_path else 'themes'
# Start with an in-progress percent below 100 so UI knows additional work remains
_write_status({"running": True, "phase": phase_label, "message": "Generating theme catalog...", "percent": 95})
# Mark that we *attempted* an export; even if it fails we won't silently skip fallback repeat
nonlocal theme_export_performed
theme_export_performed = True
from subprocess import run as _run
# Resolve absolute script paths to avoid cwd-dependent failures inside container
script_base = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'scripts'))
extract_script = os.path.join(script_base, 'extract_themes.py')
export_script = os.path.join(script_base, 'export_themes_to_yaml.py')
build_script = os.path.join(script_base, 'build_theme_catalog.py')
catalog_mode = os.environ.get('THEME_CATALOG_MODE', '').strip().lower()
# Default to merge mode if build script exists unless explicitly set to 'legacy'
use_merge = False
if os.path.exists(build_script):
if catalog_mode in {'merge', 'build', 'phaseb', ''} and catalog_mode != 'legacy':
use_merge = True
import sys as _sys
def _emit(msg: str):
try:
if out_func:
out_func(msg)
except Exception:
pass
try:
print(msg)
except Exception:
pass
if use_merge:
_emit("Attempting Phase B merged theme catalog build (build_theme_catalog.py)...")
try:
_run([_sys.executable, build_script], check=True)
_emit("Merged theme catalog build complete.")
# Ensure per-theme YAML files are also updated so editorial workflows remain intact.
if os.path.exists(export_script):
# Optional fast-path skip: if enabled via env AND we are on fast_path AND not force.
# Default behavior now: ALWAYS force export so YAML stays aligned with merged JSON output.
fast_skip = False
try:
fast_skip = fast_path and not force and os.getenv('THEME_YAML_FAST_SKIP', '0').strip() not in {'', '0', 'false', 'False', 'no', 'NO'}
except Exception:
fast_skip = False
if fast_skip:
_emit("Per-theme YAML export skipped (fast path)")
else:
exp_args = [_sys.executable, export_script, '--force'] # unconditional force now
try:
_run(exp_args, check=True)
if fast_path:
_emit("Per-theme YAML export (Phase A) completed post-merge (forced fast path).")
else:
_emit("Per-theme YAML export (Phase A) completed post-merge (forced).")
except Exception as yerr:
_emit(f"YAML export after merge failed: {yerr}")
except Exception as merge_err:
_emit(f"Merge build failed ({merge_err}); falling back to legacy extract/export.")
use_merge = False
if not use_merge:
if not os.path.exists(extract_script):
raise FileNotFoundError(f"extract script missing: {extract_script}")
if not os.path.exists(export_script):
raise FileNotFoundError(f"export script missing: {export_script}")
_emit("Refreshing theme catalog ({} path)...".format('fast' if fast_path else 'post-tagging'))
_run([_sys.executable, extract_script], check=True)
args = [_sys.executable, export_script]
if force:
args.append('--force')
_run(args, check=True)
_emit("Theme catalog (JSON + YAML) refreshed{}.".format(" (fast path)" if fast_path else ""))
# Mark progress complete
_write_status({"running": True, "phase": phase_label, "message": "Theme catalog refreshed", "percent": 99})
# Append status file enrichment with last export metrics
try:
status_path = os.path.join('csv_files', '.setup_status.json')
if os.path.exists(status_path):
with open(status_path, 'r', encoding='utf-8') as _rf:
st = json.load(_rf) or {}
else:
st = {}
st.update({
'themes_last_export_at': _dt.now().isoformat(timespec='seconds'),
'themes_last_export_fast_path': bool(fast_path),
# Populate provenance if available (Phase B/C)
})
try:
theme_json_path = os.path.join('config', 'themes', 'theme_list.json')
if os.path.exists(theme_json_path):
with open(theme_json_path, 'r', encoding='utf-8') as _tf:
_td = json.load(_tf) or {}
prov = _td.get('provenance') or {}
if isinstance(prov, dict):
for k, v in prov.items():
st[f'theme_provenance_{k}'] = v
except Exception:
pass
# Write back
with open(status_path, 'w', encoding='utf-8') as _wf:
json.dump(st, _wf)
except Exception:
pass
except Exception as _e: # pragma: no cover - non-critical diagnostics only
try:
out_func(f"Theme catalog refresh failed: {_e}")
except Exception:
pass
try:
print(f"Theme catalog refresh failed: {_e}")
except Exception:
pass
finally:
try:
# Mark phase back to done if we were otherwise complete
status_path = os.path.join('csv_files', '.setup_status.json')
if os.path.exists(status_path):
with open(status_path, 'r', encoding='utf-8') as _rf:
st = json.load(_rf) or {}
# Only flip phase if previous run finished
if st.get('phase') in {'themes','themes-fast'}:
st['phase'] = 'done'
with open(status_path, 'w', encoding='utf-8') as _wf:
json.dump(st, _wf)
except Exception:
pass
try:
cards_path = os.path.join('csv_files', 'cards.csv')
flag_path = os.path.join('csv_files', '.tagging_complete.json')
@ -910,7 +1044,9 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
duration_s = int(max(0.0, (finished_dt - start_dt).total_seconds()))
except Exception:
duration_s = None
payload = {"running": False, "phase": "done", "message": "Setup complete", "color": None, "percent": 100, "finished_at": finished}
# Generate / refresh theme catalog (JSON + per-theme YAML) BEFORE marking done so UI sees progress
_refresh_theme_catalog(out, force=True, fast_path=False)
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
_write_status(payload)
@ -919,6 +1055,116 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
except Exception:
# Non-fatal; downstream loads will still attempt and surface errors in logs
_write_status({"running": False, "phase": "error", "message": "Setup check failed"})
# Fast-path theme catalog refresh: if setup/tagging were already current (no refresh_needed executed)
# ensure theme artifacts exist and are fresh relative to the tagging flag. This runs outside the
# main try so that a failure here never blocks normal builds.
try: # noqa: E722 - defensive broad except acceptable for non-critical refresh
# Only attempt if we did NOT just perform a refresh (refresh_needed False) and auto-setup enabled
# We detect refresh_needed by checking presence of the status flag percent=100 and phase done.
status_path = os.path.join('csv_files', '.setup_status.json')
tag_flag = os.path.join('csv_files', '.tagging_complete.json')
auto_setup_enabled = _is_truthy_env('WEB_AUTO_SETUP', '1')
if not auto_setup_enabled:
return
refresh_recent = False
try:
if os.path.exists(status_path):
with open(status_path, 'r', encoding='utf-8') as _rf:
st = json.load(_rf) or {}
# If status percent just hit 100 moments ago (< 10s), we can skip fast-path work
if st.get('percent') == 100 and st.get('phase') == 'done':
# If finished very recently we assume the main export already ran
fin = st.get('finished_at') or st.get('updated')
if isinstance(fin, str) and fin.strip():
try:
ts = _dt.fromisoformat(fin.strip())
if (time.time() - ts.timestamp()) < 10:
refresh_recent = True
except Exception:
pass
except Exception:
pass
if refresh_recent:
return
theme_json = os.path.join('config', 'themes', 'theme_list.json')
catalog_dir = os.path.join('config', 'themes', 'catalog')
need_theme_refresh = False
# Helper to parse ISO timestamp
def _parse_iso(ts: str | None):
if not ts:
return None
try:
return _dt.fromisoformat(ts.strip()).timestamp()
except Exception:
return None
tag_ts = None
try:
if os.path.exists(tag_flag):
with open(tag_flag, 'r', encoding='utf-8') as f:
tag_ts = (json.load(f) or {}).get('tagged_at')
except Exception:
tag_ts = None
tag_time = _parse_iso(tag_ts)
theme_mtime = os.path.getmtime(theme_json) if os.path.exists(theme_json) else 0
# Determine newest YAML or build script mtime to detect editorial changes
newest_yaml_mtime = 0
try:
if os.path.isdir(catalog_dir):
for fn in os.listdir(catalog_dir):
if fn.endswith('.yml'):
pth = os.path.join(catalog_dir, fn)
try:
mt = os.path.getmtime(pth)
if mt > newest_yaml_mtime:
newest_yaml_mtime = mt
except Exception:
pass
except Exception:
newest_yaml_mtime = 0
build_script_path = os.path.join('code', 'scripts', 'build_theme_catalog.py')
build_script_mtime = 0
try:
if os.path.exists(build_script_path):
build_script_mtime = os.path.getmtime(build_script_path)
except Exception:
build_script_mtime = 0
# Conditions triggering refresh:
# 1. theme_list.json missing
# 2. catalog dir missing or unusually small (< 100 files) indicates first run or failure
# 3. tagging flag newer than theme_list.json (themes stale relative to data)
if not os.path.exists(theme_json):
need_theme_refresh = True
elif not os.path.isdir(catalog_dir):
need_theme_refresh = True
else:
try:
yml_count = len([p for p in os.listdir(catalog_dir) if p.endswith('.yml')])
if yml_count < 100: # heuristic threshold (we expect ~700+)
need_theme_refresh = True
except Exception:
need_theme_refresh = True
# Trigger refresh if tagging newer
if not need_theme_refresh and tag_time and tag_time > (theme_mtime + 1):
need_theme_refresh = True
# Trigger refresh if any catalog YAML newer than theme_list.json (editorial edits)
if not need_theme_refresh and newest_yaml_mtime and newest_yaml_mtime > (theme_mtime + 1):
need_theme_refresh = True
# Trigger refresh if build script updated (logic changes)
if not need_theme_refresh and build_script_mtime and build_script_mtime > (theme_mtime + 1):
need_theme_refresh = True
if need_theme_refresh:
_refresh_theme_catalog(out, force=False, fast_path=True)
except Exception:
pass
# Unconditional fallback: if (for any reason) no theme export ran above, perform a fast-path export now.
# This guarantees that clicking Run Setup/Tagging always leaves themes current even when tagging wasn't needed.
try:
if not theme_export_performed:
_refresh_theme_catalog(out, force=False, fast_path=True)
except Exception:
pass
def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, int], tag_mode: str | None = None, *, use_owned_only: bool | None = None, prefer_owned: bool | None = None, owned_names: List[str] | None = None, prefer_combos: bool | None = None, combo_target_count: int | None = None, combo_balance: str | None = None) -> Dict[str, Any]:

View file

@ -173,24 +173,32 @@
return statusEl;
}
function renderSetupStatus(data){
var el = ensureStatusEl(); if (!el) return;
if (data && data.running) {
var msg = (data.message || 'Preparing data...');
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
el.classList.add('busy');
} else if (data && data.phase === 'done') {
// Don't show "Setup complete" message to avoid UI stuttering
// Just clear any existing content and remove busy state
el.innerHTML = '';
el.classList.remove('busy');
} else if (data && data.phase === 'error') {
el.innerHTML = '<span class="error">Setup error.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
} else {
if (!el.innerHTML.trim()) el.innerHTML = '';
el.classList.remove('busy');
}
var el = ensureStatusEl(); if (!el) return;
if (data && data.running) {
var msg = (data.message || 'Preparing data...');
var pct = (typeof data.percent === 'number') ? data.percent : null;
// Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed
var suppress = false;
if (pct !== null && pct >= 99) suppress = true;
var lm = (msg || '').toLowerCase();
if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true;
if (suppress) {
if (el.innerHTML) { el.innerHTML=''; el.classList.remove('busy'); }
return;
}
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
el.classList.add('busy');
} else if (data && data.phase === 'done') {
el.innerHTML = '';
el.classList.remove('busy');
} else if (data && data.phase === 'error') {
el.innerHTML = '<span class="error">Setup error.</span>';
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
} else {
if (!el.innerHTML.trim()) el.innerHTML = '';
el.classList.remove('busy');
}
}
function pollStatus(){
try {
fetch('/status/setup', { cache: 'no-store' })

View file

@ -9,5 +9,29 @@
<a class="action-button" href="/decks">Finished Decks</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);">
<span id="themes-quick-status">Themes: …</span>
</div>
</section>
<script>
(function(){
function upd(data){
var el = document.getElementById('themes-quick-status');
if(!el) return;
if(!data || !data.ok){ el.textContent='Themes: unavailable'; return; }
var badge = '';
if(data.phase === 'themes' || data.phase === 'themes-fast') badge=' (refreshing)';
else if(data.stale) badge=' (stale)';
el.textContent = 'Themes: ' + (data.theme_count != null ? data.theme_count : '?') + badge;
}
function poll(){
fetch('/themes/status', { cache:'no-store' })
.then(function(r){ return r.json(); })
.then(upd)
.catch(function(){});
}
poll();
setInterval(poll, 7000);
})();
</script>
{% endblock %}

View file

@ -24,18 +24,37 @@
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
<button type="submit" id="btn-start-setup">Run Setup/Tagging</button>
<button type="submit" id="btn-start-setup" class="action-btn">Run Setup/Tagging</button>
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
<input type="checkbox" id="chk-force" checked /> Force run
</label>
</form>
<form method="get" action="/setup/running?start=1&force=1">
<button type="submit">Open Progress Page</button>
<button type="submit" class="action-btn">Open Progress Page</button>
</form>
</div>
<details style="margin-top:1.25rem;" open>
<summary>Theme Catalog Status</summary>
<div id="themes-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div class="muted">Status:</div>
<div id="themes-status-line" style="margin-top:.25rem;">Checking…</div>
<div class="muted" id="themes-meta-line" style="margin-top:.25rem; display:none;"></div>
<div class="muted" id="themes-stale-line" style="margin-top:.25rem; display:none; color:#f87171;"></div>
</div>
</details>
<div style="margin-top:.75rem;">
<button type="button" id="btn-refresh-themes" class="action-btn" onclick="refreshThemes()">Refresh Themes Only</button>
</div>
</section>
<script>
(function(){
// Minimal styling helper to unify button widths
try {
var style = document.createElement('style');
style.textContent = '.action-btn{min-width:180px;}';
document.head.appendChild(style);
} catch(e){}
function update(data){
var line = document.getElementById('setup-status-line');
var colorEl = document.getElementById('setup-color-line');
@ -126,7 +145,47 @@
.then(function(r){ return r.json(); })
.then(update)
.catch(function(){});
pollThemes();
}
function pollThemes(){
fetch('/themes/status', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(updateThemes)
.catch(function(){});
}
function updateThemes(data){
var line = document.getElementById('themes-status-line');
var meta = document.getElementById('themes-meta-line');
var staleEl = document.getElementById('themes-stale-line');
var btn = document.getElementById('btn-refresh-themes');
if(!line) return;
if(!data || !data.ok){ line.textContent = 'Unavailable'; return; }
var parts = [];
if (typeof data.theme_count === 'number') parts.push('Themes: '+data.theme_count);
if (typeof data.yaml_file_count === 'number') parts.push('YAML: '+data.yaml_file_count);
if (data.last_export_at) parts.push('Last Export: '+data.last_export_at);
line.textContent = (data.theme_list_exists ? 'Ready' : 'Not generated');
if(parts.length){ meta.style.display=''; meta.textContent = parts.join(' • '); } else { meta.style.display='none'; }
if(data.stale){ staleEl.style.display=''; staleEl.textContent='Stale: needs refresh'; }
else { staleEl.style.display='none'; }
// Disable refresh while a theme export phase is active (in orchestrator phases 'themes' / 'themes-fast')
try {
if(btn){
if(data.phase === 'themes' || data.phase === 'themes-fast'){
btn.disabled = true; btn.textContent='Refreshing…';
} else {
if(!data.running){ btn.disabled = false; btn.textContent='Refresh Themes Only'; }
}
}
} catch(e){}
}
window.refreshThemes = function(){
var btn = document.getElementById('btn-refresh-themes');
if(btn) btn.disabled = true;
fetch('/themes/refresh', { method:'POST' })
.then(function(){ setTimeout(function(){ pollThemes(); if(btn) btn.disabled=false; }, 1200); })
.catch(function(){ if(btn) btn.disabled=false; });
};
function rapidPoll(times, delay){
var i = 0;
function tick(){
@ -157,6 +216,7 @@
};
setInterval(poll, 3000);
poll();
pollThemes();
})();
</script>
{% endblock %}