mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-18 00:20:13 +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
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue