refactor(web): finished JavaScript consolidation, tested JavaScript items, refined themes and color palettes, tested all themes and palettes, ensured all interactive lements use theme-aware css

This commit is contained in:
matt 2025-10-29 15:45:40 -07:00
parent 9379732eec
commit 3c45a31aa3
19 changed files with 498 additions and 632 deletions

View file

@ -37,7 +37,12 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- PostCSS build pipeline with autoprefixer
- Reduced inline styles in templates (moved to shared CSS classes)
- Organized CSS into functional sections with clear documentation
- **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast
- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes
- Light mode: Darker text for improved readability, warm earth tone color palette
- Dark mode: Refined contrast for better visual hierarchy
- High-contrast mode: Optimized for maximum accessibility
- Consistent hover states across all interactive elements
- Improved visibility of form inputs and controls
- **JavaScript Modernization**: Updated to modern JavaScript patterns
- Converted `var` declarations to `const`/`let`
- Added TypeScript type annotations for better IDE support and error catching

View file

@ -34,7 +34,12 @@ Web UI improvements with Tailwind CSS migration, TypeScript conversion, componen
- PostCSS build pipeline with autoprefixer
- Reduced inline styles in templates (moved to shared CSS classes)
- Organized CSS into functional sections with clear documentation
- **Light theme visual improvements**: Warm earth tone palette with better button/panel contrast
- **Theme Visual Improvements**: Enhanced readability and consistency across all theme modes
- Light mode: Darker text for improved readability, warm earth tone color palette
- Dark mode: Refined contrast for better visual hierarchy
- High-contrast mode: Optimized for maximum accessibility
- Consistent hover states across all interactive elements
- Improved visibility of form inputs and controls
- **JavaScript Modernization**: Updated to modern JavaScript patterns
- Converted `var` declarations to `const`/`let`
- Added TypeScript type annotations for better IDE support and error catching

View file

@ -116,7 +116,7 @@
position: relative;
max-width: 720px;
width: clamp(320px, 90vw, 720px);
background: #0f1115;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
@ -197,12 +197,15 @@
font-family: monospace;
font-size: 12px;
border-left: 3px solid #4ade80;
color: #1f2937;
background: #ffffff;
border-right: 1px solid var(--border);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
color: var(--text);
background: var(--bg);
}
.include-textarea::placeholder {
color: #9ca3af;
color: var(--muted);
opacity: 0.7;
}
@ -226,12 +229,15 @@
font-family: monospace;
font-size: 12px;
border-left: 3px solid #ef4444;
color: #1f2937;
background: #ffffff;
border-right: 1px solid var(--border);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
color: var(--text);
background: var(--bg);
}
.exclude-textarea::placeholder {
color: #9ca3af;
color: var(--muted);
opacity: 0.7;
}
@ -410,7 +416,7 @@
.build-controls {
position: sticky;
z-index: 5;
background: linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85));
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.5rem;
@ -459,8 +465,8 @@
.ownership-badge {
display: inline-block;
border: 1px solid var(--border);
background: rgba(17,24,39,.9);
color: #e5e7eb;
background: var(--panel);
color: var(--text);
border-radius: 12px;
font-size: 12px;
line-height: 18px;
@ -474,7 +480,7 @@
.build-log {
margin-top: 0.5rem;
white-space: pre-wrap;
background: #0f1115;
background: var(--panel);
border: 1px solid var(--border);
padding: 1rem;
border-radius: 8px;
@ -575,8 +581,14 @@
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 8px;
background: #12161c;
background: var(--panel);
font-weight: 600;
transition: background-color 0.15s ease;
}
.combo-summary:hover {
background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%);
border-color: var(--text);
}
/* Mana analytics row grid */
@ -592,7 +604,7 @@
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem;
background: #0f1115;
background: var(--panel);
}
/* Mana panel heading */

View file

@ -1116,10 +1116,10 @@ video {
/* warm beige background (keep existing) */
--panel: #ebe5d8;
/* lighter warm cream - more contrast with bg, subtle panels */
--text: #1a1410;
/* dark brown for readability */
--muted: #6b655d;
/* warm muted brown (keep existing) */
--text: #0d0a08;
/* very dark brown/near-black for strong readability */
--muted: #5a544c;
/* darker muted brown for better contrast */
--border: #bfb5a3;
/* darker warm-gray border for better definition */
/* Navbar/banner: darker warm brown for hierarchy */
@ -1652,6 +1652,42 @@ select,input[type="text"],input[type="number"]{
padding:.35rem .4rem;
}
/* Range slider styling */
input[type="range"]{
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: var(--bg);
border-radius: 4px;
outline: none;
border: 1px solid var(--border);
}
input[type="range"]::-webkit-slider-thumb{
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: var(--blue-main);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--panel);
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
input[type="range"]::-moz-range-thumb{
width: 20px;
height: 20px;
background: var(--blue-main);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--panel);
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
fieldset{
border:1px solid var(--border);
border-radius:8px;
@ -1740,8 +1776,8 @@ small, .muted{
}
.toast{
background: rgba(17,24,39,.95);
color:#e5e7eb;
background: var(--panel);
color:var(--text);
border:1px solid var(--border);
border-radius:10px;
padding:.5rem .65rem;
@ -2084,8 +2120,8 @@ small, .muted{
position:absolute;
top:6px;
left:6px;
background:rgba(17,24,39,.9);
color:#e5e7eb;
background:var(--panel);
color:var(--text);
border:1px solid var(--border);
border-radius:12px;
font-size:12px;
@ -2211,7 +2247,7 @@ small, .muted{
width:20px;
height:20px;
border-radius:50%;
background:#1f2937;
background:var(--bg);
font-size:12px;
}
@ -2225,7 +2261,7 @@ small, .muted{
position: sticky;
top: calc(var(--banner-offset, 48px) + 6px);
z-index: 100;
background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
background: var(--panel);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 10px;
@ -2712,8 +2748,8 @@ img.lqip.loaded {
}
.analytics-accordion summary:hover {
background: #1f2937;
border-color: #374151;
background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%);
border-color: var(--text);
}
.analytics-accordion summary:active {
@ -3509,7 +3545,7 @@ img.lqip.loaded {
.modal-content {
position: relative;
background: #0f1115;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);

View file

@ -41,8 +41,8 @@
[data-theme="light-blend"]{
--bg: #e8e2d0; /* warm beige background (keep existing) */
--panel: #ebe5d8; /* lighter warm cream - more contrast with bg, subtle panels */
--text: #1a1410; /* dark brown for readability */
--muted: #6b655d; /* warm muted brown (keep existing) */
--text: #0d0a08; /* very dark brown/near-black for strong readability */
--muted: #5a544c; /* darker muted brown for better contrast */
--border: #bfb5a3; /* darker warm-gray border for better definition */
/* Navbar/banner: darker warm brown for hierarchy */
--surface-banner: #9b8f7a; /* warm medium brown - darker than panels, lighter than dark theme */
@ -214,6 +214,37 @@ label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75r
.mana-G{ background:#10b981; border-color:#047857; }
.mana-C{ background:#d3d3d3; border-color:#9ca3af; }
select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
/* Range slider styling */
input[type="range"]{
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: var(--bg);
border-radius: 4px;
outline: none;
border: 1px solid var(--border);
}
input[type="range"]::-webkit-slider-thumb{
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: var(--blue-main);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--panel);
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
input[type="range"]::-moz-range-thumb{
width: 20px;
height: 20px;
background: var(--blue-main);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--panel);
box-shadow: 0 2px 4px rgba(0,0,0,.2);
}
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); }
.partner-preview{ border:1px solid var(--border); border-radius:8px; background: var(--panel); padding:.75rem; margin-bottom:.5rem; }
@ -231,7 +262,7 @@ small, .muted{ color: var(--muted); }
/* Toasts */
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
.toast{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
.toast.hide{ opacity:0; transform: translateY(6px); }
.toast.success{ border-color: rgba(22,163,74,.4); }
.toast.error{ border-color: rgba(239,68,68,.45); }
@ -403,8 +434,8 @@ small, .muted{ color: var(--muted); }
position:absolute;
top:6px;
left:6px;
background:rgba(17,24,39,.9);
color:#e5e7eb;
background:var(--panel);
color:var(--text);
border:1px solid var(--border);
border-radius:12px;
font-size:12px;
@ -448,7 +479,7 @@ small, .muted{ color: var(--muted); }
.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background: var(--panel); border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
.stage-nav .stage-item.done .stage-link { opacity:.75; }
.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:var(--bg); font-size:12px; }
.stage-nav .name { font-size:12px; }
/* Build controls sticky box tweaks */
@ -456,7 +487,7 @@ small, .muted{ color: var(--muted); }
position: sticky;
top: calc(var(--banner-offset, 48px) + 6px);
z-index: 100;
background: linear-gradient(180deg, rgba(15,17,21,.98), rgba(15,17,21,.92));
background: var(--panel);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 10px;
@ -678,8 +709,8 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
}
.analytics-accordion summary:hover {
background: #1f2937;
border-color: #374151;
background: color-mix(in srgb, var(--bg) 70%, var(--text) 30%);
border-color: var(--text);
}
.analytics-accordion summary:active {
@ -1434,7 +1465,7 @@ img.lqip.loaded { filter: blur(0); opacity: 1; }
.modal-content {
position: relative;
background: #0f1115;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);

View file

@ -1409,4 +1409,294 @@ interface SkeletonManager {
// Initialize on load and after HTMX swaps
document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(document.body); });
document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); });
// =============================================================================
// UTILITIES EXTRACTED FROM BASE.HTML INLINE SCRIPTS (Phase 3)
// =============================================================================
/**
* Poll setup status endpoint for progress updates
* Shows dynamic status message in #banner-status element
*/
function initSetupStatusPoller(): void {
let statusEl: HTMLElement | null = null;
function ensureStatusEl(): HTMLElement | null {
if (!statusEl) statusEl = document.getElementById('banner-status');
return statusEl;
}
function renderSetupStatus(data: any): void {
const el = ensureStatusEl();
if (!el) return;
if (data && data.running) {
const msg = data.message || 'Preparing data...';
const pct = (typeof data.percent === 'number') ? data.percent : null;
// Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed
let suppress = false;
if (pct !== null && pct >= 99) suppress = true;
const 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(): void {
try {
fetch('/status/setup', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(renderSetupStatus)
.catch(function(){ /* noop */ });
} catch(_){}
}
// Poll every 10 seconds to reduce server load (only for header indicator)
setInterval(pollStatus, 10000);
pollStatus(); // Initial poll
}
/**
* Highlight active navigation link based on current path
* Matches exact or prefix paths, prioritizing longer matches
*/
function initActiveNavHighlighter(): void {
try {
const path = window.location.pathname || '/';
const nav = document.getElementById('primary-nav');
if (!nav) return;
const links = nav.querySelectorAll('a');
let best: HTMLAnchorElement | null = null;
let bestLen = -1;
links.forEach(function(a){
const href = a.getAttribute('href') || '';
if (!href) return;
// Exact match or prefix match (ignoring trailing slash)
if (path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){
if (href.length > bestLen){
best = a as HTMLAnchorElement;
bestLen = href.length;
}
}
});
if (best) best.classList.add('active');
} catch(_){}
}
/**
* Initialize theme selector dropdown and persistence
* Handles localStorage, URL overrides, and system preference tracking
*/
function initThemeSelector(enableThemes: boolean, defaultTheme: string): void {
if (!enableThemes) return;
try {
const sel = document.getElementById('theme-select') as HTMLSelectElement | null;
const resetBtn = document.getElementById('theme-reset');
const root = document.documentElement;
const KEY = 'mtg:theme';
const SERVER_DEFAULT = defaultTheme;
function mapLight(v: string): string {
return v === 'light' ? 'light-blend' : v;
}
function resolveSystem(): string {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
function normalizeUiValue(v: string): string {
const x = (v || 'system').toLowerCase();
if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
return x;
}
function apply(val: string): void {
let v = (val || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
v = mapLight(v);
root.setAttribute('data-theme', v);
}
// Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
const params = new URLSearchParams(window.location.search || '');
const urlTheme = (params.get('theme') || '').toLowerCase();
if (urlTheme) {
// Persist the UI value, not the mapped CSS token
localStorage.setItem(KEY, normalizeUiValue(urlTheme));
// Clean the URL so reloads don't keep overriding
try {
const u = new URL(window.location.href);
u.searchParams.delete('theme');
window.history.replaceState({}, document.title, u.toString());
} catch(_){}
}
// Determine initial selection: URL -> localStorage -> server default -> system
const stored = localStorage.getItem(KEY);
const initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
apply(initial);
if (sel) {
sel.value = normalizeUiValue(initial);
sel.addEventListener('change', function(){
const v = sel.value || 'system';
localStorage.setItem(KEY, v);
apply(v);
});
}
if (resetBtn) {
resetBtn.addEventListener('click', function(){
try { localStorage.removeItem(KEY); } catch(_){}
const v = SERVER_DEFAULT || 'system';
apply(v);
if (sel) sel.value = normalizeUiValue(v);
});
}
// React to system changes when set to system
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){
const cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
if (cur === 'system') apply('system');
});
}
} catch(_){}
}
/**
* Apply theme from environment variable when selector is disabled
* Resolves 'system' to OS preference
*/
function initThemeEnvOnly(enableThemes: boolean, defaultTheme: string): void {
if (enableThemes) return; // Only run when themes are disabled
try {
const root = document.documentElement;
const SERVER_DEFAULT = defaultTheme;
function resolveSystem(): string {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
let v = (SERVER_DEFAULT || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
if (v === 'light') v = 'light-blend';
root.setAttribute('data-theme', v);
// Track OS changes when using system
if ((SERVER_DEFAULT || 'system').toLowerCase() === 'system' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){
root.setAttribute('data-theme', resolveSystem());
});
}
} catch(_){}
}
/**
* Register PWA service worker and handle updates
* Automatically reloads when new version is available
*/
function initServiceWorker(enablePwa: boolean, catalogHash: string): void {
if (!enablePwa) return;
try {
if ('serviceWorker' in navigator) {
const ver = catalogHash || 'dev';
const url = '/static/sw.js?v=' + encodeURIComponent(ver);
navigator.serviceWorker.register(url).then(function(reg){
(window as any).__pwaStatus = { registered: true, scope: reg.scope, version: ver };
// Listen for updates (new worker installing)
if (reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
reg.addEventListener('updatefound', function(){
try {
const nw = reg.installing;
if (!nw) return;
nw.addEventListener('statechange', function(){
if (nw.state === 'installed' && navigator.serviceWorker.controller) {
// New version available; reload silently for freshness
try {
sessionStorage.setItem('mtg:swUpdated', '1');
} catch(_){}
window.location.reload();
}
});
} catch(_){}
});
}).catch(function(){
(window as any).__pwaStatus = { registered: false };
});
}
} catch(_){}
}
/**
* Show toast after page reload
* Used when actions replace the whole document
*/
function initToastAfterReload(): void {
try {
const raw = sessionStorage.getItem('mtg:toastAfterReload');
if (raw) {
sessionStorage.removeItem('mtg:toastAfterReload');
const data = JSON.parse(raw);
if (data && data.msg) {
window.toast && window.toast(data.msg, data.type || '');
}
}
} catch(_){}
}
// Initialize all utilities on DOMContentLoaded
document.addEventListener('DOMContentLoaded', function(){
initSetupStatusPoller();
initActiveNavHighlighter();
initToastAfterReload();
// Theme and PWA initialization require server-injected values
// These will be called from base.html inline scripts that pass the values
// window.__initThemeSelector, window.__initThemeEnvOnly, window.__initServiceWorker
});
// Expose functions globally for inline script calls (with server values)
(window as any).__initThemeSelector = initThemeSelector;
(window as any).__initThemeEnvOnly = initThemeEnvOnly;
(window as any).__initServiceWorker = initServiceWorker;
})();

View file

@ -117,7 +117,7 @@ interface PointerEventLike {
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'Card detail hover panel');
panel.setAttribute('aria-hidden', 'true');
panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:#1f2937;border:1px solid #374151;border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:#f3f4f6;font-size:14px;line-height:1.45;pointer-events:none;';
panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:var(--text);font-size:14px;line-height:1.45;pointer-events:none;';
panel.innerHTML = '' +
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">' +
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">&nbsp;</div>' +
@ -126,11 +126,11 @@ interface PointerEventLike {
'</div>' +
'<div class="hcp-body">' +
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">' +
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid #475569;background:#0b0d12;opacity:1;" />' +
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid var(--border);background:var(--bg);opacity:1;" />' +
'</div>' +
'<div class="hcp-right" style="display:flex;flex-direction:column;min-width:0;">' +
'<div style="display:flex;align-items:center;gap:6px;margin:0 0 4px;flex-wrap:wrap;">' +
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid #475569;border-radius:12px;background:#243044;text-transform:uppercase;">&nbsp;</div>' +
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid var(--border);border-radius:12px;background:var(--bg);text-transform:uppercase;">&nbsp;</div>' +
'<div class="hcp-overlaps" style="flex:1;min-height:14px;"></div>' +
'</div>' +
'<ul class="hcp-taglist" aria-label="Themes"></ul>' +

View file

@ -66,6 +66,7 @@
</button>
<h1 style="margin:0; white-space: nowrap;">MTG Deckbuilder</h1>
</div>
<div id="banner-status" class="banner-status" role="status" aria-live="polite"></div>
</div>
</header>
<div class="layout">
@ -195,50 +196,7 @@
document.body.classList.remove('htmx-settling');
});
// Setup/Tagging status poller
var statusEl;
function ensureStatusEl(){
if (!statusEl) statusEl = document.getElementById('banner-status');
return statusEl;
}
function renderSetupStatus(data){
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' })
.then(function(r){ return r.json(); })
.then(renderSetupStatus)
.catch(function(){ /* noop */ });
} catch(e) {}
}
// Poll every 10 seconds instead of 3 to reduce server load (only for header indicator)
setInterval(pollStatus, 10000);
pollStatus();
// Setup/Tagging status poller moved to app.ts (initSetupStatusPoller)
// Expose normalizeCardName for cardImages module
window.__normalizeCardName = function(raw){
@ -390,163 +348,45 @@
try { observer.observe(document.body, { childList:true, subtree:true }); } catch(_){ }
})();
</script>
<script>
(function(){
try {
var path = window.location.pathname || '/';
var nav = document.getElementById('primary-nav'); if(!nav) return;
var links = nav.querySelectorAll('a');
var best = null; var bestLen = -1;
links.forEach(function(a){
var href = a.getAttribute('href') || '';
if(!href) return;
// Exact match or prefix match (ignoring trailing slash)
if(path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){
if(href.length > bestLen){ best = a; bestLen = href.length; }
}
});
if(best) best.classList.add('active');
} catch(_) {}
})();
</script>
<!-- Active nav highlighter moved to app.ts (initActiveNavHighlighter) -->
<script src="/static/js/components.js?v=20251028-1"></script>
<script src="/static/js/app.js?v=20250826-4"></script>
<script src="/static/js/app.js?v=20251029-2"></script>
<script src="/static/js/cardImages.js?v=20251029-1"></script>
<script src="/static/js/cardHover.js?v=20251028-1"></script>
{% if enable_themes %}
<script>
(function(){
try{
var sel = document.getElementById('theme-select');
var resetBtn = document.getElementById('theme-reset');
var root = document.documentElement;
var KEY = 'mtg:theme';
var SERVER_DEFAULT = '{{ default_theme }}';
function mapLight(v){ return v === 'light' ? 'light-blend' : v; }
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
function normalizeUiValue(v){
var x = (v||'system').toLowerCase();
if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
return x;
}
function apply(val){
var v = (val || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
v = mapLight(v);
root.setAttribute('data-theme', v);
}
// Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
var params = new URLSearchParams(window.location.search || '');
var urlTheme = (params.get('theme') || '').toLowerCase();
if (urlTheme) {
// Persist the UI value, not the mapped CSS token
localStorage.setItem(KEY, normalizeUiValue(urlTheme));
// Clean the URL so reloads don't keep overriding
try { var u = new URL(window.location.href); u.searchParams.delete('theme'); window.history.replaceState({}, document.title, u.toString()); } catch(_){ }
}
// Determine initial selection: URL -> localStorage -> server default -> system
var stored = localStorage.getItem(KEY);
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
apply(initial);
if (sel){
sel.value = normalizeUiValue(initial);
sel.addEventListener('change', function(){
var v = sel.value || 'system';
localStorage.setItem(KEY, v);
apply(v);
});
}
if (resetBtn){
resetBtn.addEventListener('click', function(){
try{ localStorage.removeItem(KEY); }catch(_){ }
var v = SERVER_DEFAULT || 'system';
apply(v);
if (sel) sel.value = normalizeUiValue(v);
});
}
// React to system changes when set to system
if (window.matchMedia){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){
var cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
if (cur === 'system') apply('system');
});
}
}catch(_){ }
})();
// Theme selector logic moved to app.ts (initThemeSelector)
// Call it with server-injected values on DOMContentLoaded
document.addEventListener('DOMContentLoaded', function(){
if (window.__initThemeSelector) {
window.__initThemeSelector({{ enable_themes|lower }}, '{{ default_theme }}');
}
});
</script>
{% endif %}
{% if not enable_themes %}
<script>
(function(){
try{
// Apply THEME env even when the selector is disabled. Resolve 'system' to OS preference.
var root = document.documentElement;
var SERVER_DEFAULT = '{{ default_theme }}';
function resolveSystem(){
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light-blend';
}
var v = (SERVER_DEFAULT || 'system').toLowerCase();
if (v === 'system') v = resolveSystem();
if (v === 'light') v = 'light-blend';
root.setAttribute('data-theme', v);
// Track OS changes when using system
if ((SERVER_DEFAULT||'system').toLowerCase() === 'system' && window.matchMedia){
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', function(){ root.setAttribute('data-theme', resolveSystem()); });
}
}catch(_){ }
})();
// Theme env-only logic moved to app.ts (initThemeEnvOnly)
// Call it with server-injected values on DOMContentLoaded
document.addEventListener('DOMContentLoaded', function(){
if (window.__initThemeEnvOnly) {
window.__initThemeEnvOnly({{ enable_themes|lower }}, '{{ default_theme }}');
}
});
</script>
{% endif %}
{% if enable_pwa %}
<script>
(function(){
try{
if ('serviceWorker' in navigator){
var ver = '{{ catalog_hash|default("dev") }}';
var url = '/static/sw.js?v=' + encodeURIComponent(ver);
navigator.serviceWorker.register(url).then(function(reg){
window.__pwaStatus = { registered: true, scope: reg.scope, version: ver };
// Listen for updates (new worker installing)
if(reg.waiting){ reg.waiting.postMessage({ type: 'SKIP_WAITING' }); }
reg.addEventListener('updatefound', function(){
try {
var nw = reg.installing; if(!nw) return;
nw.addEventListener('statechange', function(){
if(nw.state === 'installed' && navigator.serviceWorker.controller){
// New version available; reload silently for freshness
try { sessionStorage.setItem('mtg:swUpdated','1'); }catch(_){ }
window.location.reload();
}
});
}catch(_){ }
});
}).catch(function(){ window.__pwaStatus = { registered: false }; });
}
}catch(_){ }
})();
// Service worker registration moved to app.ts (initServiceWorker)
// Call it with server-injected values on DOMContentLoaded
document.addEventListener('DOMContentLoaded', function(){
if (window.__initServiceWorker) {
window.__initServiceWorker({{ enable_pwa|lower }}, '{{ catalog_hash|default("dev") }}');
}
});
</script>
{% endif %}
<script>
// Show pending toast after full page reloads when actions replace the whole document
(function(){
try{
var raw = sessionStorage.getItem('mtg:toastAfterReload');
if (raw){
sessionStorage.removeItem('mtg:toastAfterReload');
var data = JSON.parse(raw);
if (data && data.msg){ window.toast && window.toast(data.msg, data.type||''); }
}
}catch(_){ }
})();
</script>
<script>
<!-- Hover card panel system moved to TypeScript: code/web/static/ts/cardHover.ts -->
</script>
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
<!-- Hover card panel system moved to cardHover.ts -->
</body>
</html>

View file

@ -9,40 +9,41 @@
left: 0;
right: 0;
z-index: 1000;
background: var(--card-bg, #1a1d24);
border: 1px solid var(--border, #374151);
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
color: var(--text);
}
.autocomplete-dropdown:empty { display: none; }
.autocomplete-item {
padding: .75rem;
cursor: pointer;
border-bottom: 1px solid rgba(55, 65, 81, 0.5);
border-bottom: 1px solid var(--border);
transition: background 0.15s;
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:hover, .autocomplete-item:focus, .autocomplete-item.selected {
background: rgba(148, 163, 184, .15);
background: var(--bg);
}
.autocomplete-item.selected {
background: rgba(148, 163, 184, .25);
border-left: 3px solid var(--ring, #3b82f6);
background: var(--bg);
border-left: 3px solid var(--ring);
padding-left: calc(.75rem - 3px);
}
.autocomplete-empty {
padding: .75rem;
text-align: center;
color: var(--muted, #9ca3af);
color: var(--muted);
font-size: .85rem;
}
.autocomplete-error {
padding: .75rem;
text-align: center;
color: #f87171;
color: var(--err);
font-size: .85rem;
}

View file

@ -3,7 +3,7 @@
{ 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] }
]
#}
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;" data-skeleton data-skeleton-label="Pulling alternatives…" data-skeleton-delay="450">
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:var(--panel);" data-skeleton data-skeleton-label="Pulling alternatives…" data-skeleton-delay="450">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem; gap:.5rem; flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:.5rem;">
<strong>Alternatives</strong>

View file

@ -35,7 +35,8 @@
style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label style="flex:1; min-width:220px;">
<span class="sr-only">Theme name</span>
<input type="text" name="theme" data-theme-input placeholder="e.g., Lifegain" maxlength="60" autocomplete="off" autocapitalize="off" spellcheck="false" style="width:100%; padding:.5rem; border-radius:6px; border:1px solid var(--border); background:var(--input-bg, #161921); color:var(--text-color, #f9fafb);" {% if disable_add %}disabled aria-disabled="true"{% endif %} />
<input type="text" name="theme" data-theme-input placeholder="e.g., Lifegain"
style="width:100%; padding:.5rem; border-radius:6px; border:1px solid var(--border); background:var(--panel); color:var(--text);" />
</label>
<button type="button" data-theme-add-btn class="btn" style="padding:.45rem 1rem;" {% if disable_add %}disabled aria-disabled="true"{% endif %}>Add theme</button>
</div>

View file

@ -198,15 +198,15 @@
/* Autocomplete dropdown styles */
.autocomplete-container { position:relative; width:100%; }
.autocomplete-dropdown { position:absolute; top:100%; left:0; right:0; z-index:1000; background:var(--panel); border:1px solid var(--border); border-radius:8px; margin-top:4px; max-height:280px; overflow-y:auto; box-shadow:0 4px 12px rgba(0,0,0,.25); display:none; }
.autocomplete-dropdown { position:absolute; top:100%; left:0; right:0; z-index:1000; background:var(--panel); border:1px solid var(--border); border-radius:8px; margin-top:4px; max-height:280px; overflow-y:auto; box-shadow:0 4px 12px rgba(0,0,0,.25); display:none; color:var(--text); }
.autocomplete-dropdown:not(:empty) { display:block; }
.autocomplete-item { padding:.5rem .75rem; cursor:pointer; border-bottom:1px solid var(--border); transition:background .15s ease; }
.autocomplete-item:last-child { border-bottom:none; }
.autocomplete-item:hover, .autocomplete-item:focus, .autocomplete-item.selected { background:rgba(148,163,184,.15); }
.autocomplete-item.selected { background:rgba(148,163,184,.25); border-left:3px solid var(--ring); padding-left:calc(.75rem - 3px); }
.autocomplete-item:hover, .autocomplete-item:focus, .autocomplete-item.selected { background:var(--bg); }
.autocomplete-item.selected { background:var(--bg); border-left:3px solid var(--ring); padding-left:calc(.75rem - 3px); }
.autocomplete-item .tag-count { color:var(--muted); font-size:.85rem; float:right; }
.autocomplete-empty { padding:.75rem; text-align:center; color:var(--muted); font-size:.85rem; }
.autocomplete-error { padding:.75rem; text-align:center; color:#f87171; font-size:.85rem; }
.autocomplete-error { padding:.75rem; text-align:center; color:var(--err); font-size:.85rem; }
</style>
<script>
(function(){

View file

@ -9,7 +9,7 @@
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong style="font-size:14px;">Example: {{ example_name }}</strong>
</div>
<pre style="margin-top:.35rem; background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px; max-height:300px; overflow:auto; white-space:pre;">{{ example_json or '{\n "commander": "Your Commander Name",\n "primary_tag": "Your Main Theme",\n "secondary_tag": null,\n "tertiary_tag": null,\n "bracket_level": 0,\n "ideal_counts": {\n "ramp": 10,\n "lands": 35,\n "basic_lands": 20,\n "fetch_lands": 3,\n "creatures": 28,\n "removal": 10,\n "wipes": 2,\n "card_advantage": 8,\n "protection": 4\n }\n}' }}</pre>
<pre style="margin-top:.35rem; background:var(--panel); border:1px solid var(--border); padding:.75rem; border-radius:8px; max-height:300px; overflow:auto; white-space:pre;">{{ example_json or '{\n "commander": "Your Commander Name",\n "primary_tag": "Your Main Theme",\n "secondary_tag": null,\n "tertiary_tag": null,\n "bracket_level": 0,\n "ideal_counts": {\n "ramp": 10,\n "lands": 35,\n "basic_lands": 20,\n "fetch_lands": 3,\n "creatures": 28,\n "removal": 10,\n "wipes": 2,\n "card_advantage": 8,\n "protection": 4\n }\n}' }}</pre>
</div>
</div>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
@ -19,7 +19,7 @@
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload JSON</button>
<input id="upload-json" type="file" name="file" accept="application/json" style="display:none" onchange="this.form.requestSubmit();">
</form>
<input id="config-filter" type="search" placeholder="Filter by commander or tag..." style="flex:1; max-width:360px; padding:.4rem .6rem; border-radius:8px; border:1px solid var(--border); background:#0f1115; color:#e5e7eb;" />
<input id="config-filter" type="search" placeholder="Filter by commander or tag..." style="flex:1; max-width:360px; padding:.4rem .6rem; border-radius:8px; border:1px solid var(--border); background:var(--panel); color:var(--text);" />
</div>
<script>
(function(){

View file

@ -24,7 +24,7 @@
every <input type="number" id="autoRefreshInterval" value="3" min="1" max="30" style="width:60px"> s
</label>
</form>
<pre id="logTail" class="log-tail" data-tail="{{ tail }}" data-q="{{ q }}" data-level="{{ level or 'all' }}" style="white-space: pre-wrap; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:8px; padding:.75rem; margin-top:.75rem; max-height:60vh; overflow:auto">{{ lines | join('') }}</pre>
<pre id="logTail" class="log-tail" data-tail="{{ tail }}" data-q="{{ q }}" data-level="{{ level or 'all' }}" style="white-space: pre-wrap; background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:.75rem; margin-top:.75rem; max-height:60vh; overflow:auto">{{ lines | join('') }}</pre>
<script>
(function(){
var pre = document.getElementById('logTail');

View file

@ -38,22 +38,22 @@
{% if names and names|length %}
<div class="filters" style="display:flex; flex-wrap:wrap; gap:8px; margin:.25rem 0 .5rem 0;">
<select id="sort-by" data-pref="owned:sort" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<select id="sort-by" data-pref="owned:sort" style="background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="name">Sort: A → Z</option>
<option value="type">Sort: Type</option>
<option value="color">Sort: Color</option>
<option value="tags">Sort: Tags</option>
<option value="recent">Sort: Recently added</option>
</select>
<select id="filter-type" data-pref="owned:type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<select id="filter-type" data-pref="owned:type" style="background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Types</option>
{% for t in all_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-tag" data-pref="owned:tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
<select id="filter-tag" data-pref="owned:tag" style="background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
<option value="">All Themes</option>
{% for t in all_tags %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-color" data-pref="owned:color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<select id="filter-color" data-pref="owned:color" style="background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Colors</option>
{% for c in all_colors %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
{% if color_combos and color_combos|length %}
@ -63,14 +63,14 @@
{% endfor %}
{% endif %}
</select>
<input id="filter-text" data-pref="owned:q" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
<input id="filter-text" data-pref="owned:q" type="search" placeholder="Search name..." style="background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
<button type="button" id="clear-filters">Clear</button>
</div>
<div id="active-chips" class="muted" style="display:flex; flex-wrap:wrap; gap:6px; font-size:12px; margin:.25rem 0 .5rem 0;"></div>
{% endif %}
{% if names and names|length %}
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;" {% if virtualize and count > 800 %}data-virtualize="1"{% endif %}>
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:var(--panel); color:var(--text); min-height:240px;" {% if virtualize and count > 800 %}data-virtualize="1"{% endif %}>
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
{% for n in names %}
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}

View file

@ -19,17 +19,17 @@
{% if tb and tb.counts %}
<style>
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.seg-btn { background:#12161c; color:#e5e7eb; border:none; padding:.35rem .6rem; cursor:pointer; font-size:12px; }
.seg-btn[aria-selected="true"] { background:#1f2937; }
.seg-btn { background:var(--panel); color:var(--text); border:none; padding:.35rem .6rem; cursor:pointer; font-size:12px; }
.seg-btn[aria-selected="true"] { background:var(--bg); }
.typeview { margin-top:.25rem; }
.typeview.hidden { display:none; }
.stack-wrap { --card-w: 160px; --card-h: 224px; --cols: 9; --overlap: .5; overflow: visible; padding: 6px 0 calc(var(--card-h) * (1 - var(--overlap))) 0; }
.stack-grid { display: grid; grid-template-columns: repeat(var(--cols), var(--card-w)); grid-auto-rows: calc(var(--card-h) * var(--overlap)); column-gap: 10px; }
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:var(--panel); transition: transform .06s ease, box-shadow .06s ease; position: relative; }
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
.count-badge { position:absolute; top:6px; right:6px; background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.owned-badge { position:absolute; top:6px; left:6px; background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
.dfc-thumb-badge { position:absolute; bottom:8px; left:6px; background:rgba(15,23,42,.92); border:1px solid #34d399; color:#bbf7d0; border-radius:12px; font-size:11px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
.dfc-thumb-badge.counts { border-color:#60a5fa; color:#bfdbfe; }
.owned-flag { font-size:.95rem; opacity:.9; }
@ -52,7 +52,7 @@
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-row .flip-slot { min-width:2.4em; display:flex; justify-content:flex-start; align-items:center; }
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
.dfc-land-chip { display:inline-flex; align-items:center; gap:.25rem; padding:2px 6px; border-radius:999px; font-size:11px; font-weight:600; background:#0f172a; border:1px solid #334155; color:#e5e7eb; line-height:1; }
.dfc-land-chip { display:inline-flex; align-items:center; gap:.25rem; padding:2px 6px; border-radius:999px; font-size:11px; font-weight:600; background:var(--panel); border:1px solid var(--border); color:var(--text); line-height:1; }
.dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; }
.dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; }
</style>
@ -519,7 +519,7 @@
</details>
</section>
<style>
.chart-tooltip { position: fixed; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); max-width: 90vw; }
.chart-tooltip { position: fixed; background: var(--panel); color: var(--text); border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); max-width: 90vw; }
/* Pinned tooltip gets pointer events for Copy button */
.chart-tooltip.pinned { pointer-events: auto; border-color: #f59e0b; box-shadow: 0 4px 20px rgba(245,158,11,.3); }
/* Unpinned tooltip has no pointer events (hover only) */
@ -533,6 +533,8 @@
/* Chart columns get cursor pointer */
.chart-column svg { cursor: pointer; transition: opacity 0.15s ease; }
.chart-column svg:hover { opacity: 0.85; }
/* Chart bar backgrounds - use CSS variable for theme support */
.chart-svg rect:first-of-type { fill: var(--bg) !important; }
</style>
<script>
(function() {

View file

@ -13,8 +13,8 @@
color: #60a5fa;
}
#content details > div {
background: #050607 !important;
border-color: #1e293b !important;
background: var(--panel) !important;
border-color: var(--border) !important;
}
#content .muted {
color: #94a3b8;
@ -37,12 +37,12 @@
}
/* Progress bars */
#content [id$="-progress-bar"] {
background: #0a0c10 !important;
background: var(--bg) !important;
}
/* Log output areas */
#content pre {
background: #030405 !important;
border-color: #1e293b !important;
background: var(--panel) !important;
border-color: var(--border) !important;
}
</style>
@ -52,25 +52,25 @@
<details open style="margin-top:.5rem;">
<summary>Current Status</summary>
<div id="setup-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div id="setup-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
<div class="muted">Status:</div>
<div id="setup-status-line" style="margin-top:.25rem;">Checking…</div>
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:var(--bg); border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
</div>
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:var(--panel); border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
</details>
</div>
</details>
<details style="margin-top:1rem;">
<summary>Download Pre-tagged Database from GitHub (Optional)</summary>
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
<p class="muted" style="margin:0 0 .75rem 0; font-size:.9rem;">
Download pre-tagged card database and similarity cache from GitHub (updated weekly).
<strong>Note:</strong> A fresh local tagging run will be most up-to-date with the latest card data.
@ -83,7 +83,7 @@
{% if image_cache_enabled %}
<details style="margin-top:1rem;">
<summary>Download Card Images (Optional)</summary>
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
<p class="muted" style="margin:0 0 .75rem 0; font-size:.9rem;">
Download card images from Scryfall CDN for faster loading and offline use.
<strong>Note:</strong> Requires ~3-6 GB disk space and 1-2 hours download time (~30k cards).
@ -95,7 +95,7 @@
</div>
{{ button('Download Card Images', variant='priamry', onclick='downloadCardImages()', attrs='id="btn-download-images"') }}
<div id="image-download-status" class="muted" style="margin-top:.5rem; display:none;"></div>
<div id="image-progress-bar" style="margin-top:.5rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="image-progress-bar" style="margin-top:.5rem; width:100%; height:10px; background:var(--bg); border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
<div id="image-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
</div>
</div>
@ -116,7 +116,7 @@
<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 id="themes-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); 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>
@ -130,7 +130,7 @@
{% if similarity_enabled %}
<details style="margin-top:1.25rem;" open>
<summary>Similarity Cache Status</summary>
<div id="similarity-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
<div id="similarity-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
<div class="muted">Status:</div>
<div id="similarity-status-line" style="margin-top:.25rem;">Checking…</div>
<div class="muted" id="similarity-meta-line" style="margin-top:.25rem; display:none;"></div>

View file

@ -24359,370 +24359,12 @@
"generated_from": "merge (analytics + curated YAML + whitelist)",
"metadata_info": {
"mode": "merge",
"generated_at": "2025-10-18T20:47:46",
"generated_at": "2025-10-29T18:16:15",
"curated_yaml_files": 740,
"synergy_cap": 5,
"inference": "pmi",
"version": "phase-b-merge-v1",
"catalog_hash": "78f24ccdca52d048d5325bd6a16dc2ad3ec3826119adbf75985c64617355b79b"
},
"description_fallback_summary": {
"total_themes": 740,
"generic_total": 286,
"generic_with_synergies": 254,
"generic_plain": 32,
"generic_pct": 38.65,
"top_generic_by_frequency": [
{
"theme": "Adamant",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Adamant leveraging synergies with +1/+1 Counters and Counters Matter."
},
{
"theme": "Adapt",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Adapt leveraging synergies with +1/+1 Counters and Counters Matter."
},
{
"theme": "Addendum",
"popularity_bucket": "Rare",
"synergy_count": 3,
"total_frequency": 0,
"description": "Builds around Addendum leveraging synergies with Interaction and Spells Matter."
},
{
"theme": "Afflict",
"popularity_bucket": "Rare",
"synergy_count": 4,
"total_frequency": 0,
"description": "Builds around Afflict leveraging synergies with Zombie Kindred and Burn."
},
{
"theme": "Afterlife",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Afterlife leveraging synergies with Spirit Kindred and Sacrifice Matters."
},
{
"theme": "Airbending",
"popularity_bucket": "Rare",
"synergy_count": 0,
"total_frequency": 0,
"description": "Builds around the Airbending theme and its supporting synergies."
},
{
"theme": "Alliance",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Alliance leveraging synergies with Druid Kindred and Elf Kindred."
},
{
"theme": "Amass",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Amass leveraging synergies with Army Kindred and Orc Kindred."
},
{
"theme": "Amplify",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Amplify leveraging synergies with +1/+1 Counters and Counters Matter."
},
{
"theme": "Annihilator",
"popularity_bucket": "Rare",
"synergy_count": 0,
"total_frequency": 0,
"description": "Builds around the Annihilator theme and its supporting synergies."
},
{
"theme": "Ascend",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Ascend leveraging synergies with Little Fellas."
},
{
"theme": "Assist",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Assist leveraging synergies with Big Mana and Interaction."
},
{
"theme": "Awaken",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Awaken leveraging synergies with Elemental Kindred and Lands Matter."
},
{
"theme": "Backup",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Backup leveraging synergies with +1/+1 Counters and Blink."
},
{
"theme": "Banding",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Banding leveraging synergies with First strike and Soldier Kindred."
},
{
"theme": "Bargain",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Bargain leveraging synergies with Blink and Enter the Battlefield."
},
{
"theme": "Basic landcycling",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Basic landcycling leveraging synergies with Landcycling and Cycling."
},
{
"theme": "Battalion",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Battalion leveraging synergies with Human Kindred and Aggro."
},
{
"theme": "Battle Cry",
"popularity_bucket": "Rare",
"synergy_count": 2,
"total_frequency": 0,
"description": "Builds around Battle Cry leveraging synergies with Aggro and Combat Matters."
},
{
"theme": "Battles Matter",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Battles Matter leveraging synergies with Transform and Card Draw."
},
{
"theme": "Behold",
"popularity_bucket": "Rare",
"synergy_count": 3,
"total_frequency": 0,
"description": "Builds around the Behold theme and its supporting synergies."
},
{
"theme": "Bending",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Bending leveraging synergies with Earthbending and Waterbending."
},
{
"theme": "Bestow",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Bestow leveraging synergies with Equipment Matters and Auras."
},
{
"theme": "Blitz",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Blitz leveraging synergies with Midrange and Unconditional Draw."
},
{
"theme": "Board Wipes",
"popularity_bucket": "Common",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Board Wipes leveraging synergies with Pingers and Interaction."
},
{
"theme": "Boast",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Boast leveraging synergies with Warrior Kindred and Human Kindred."
},
{
"theme": "Bolster",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Bolster leveraging synergies with +1/+1 Counters and Combat Tricks."
},
{
"theme": "Bushido",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Bushido leveraging synergies with Samurai Kindred and Fox Kindred."
},
{
"theme": "Cantrips",
"popularity_bucket": "Common",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Cantrips leveraging synergies with Clue Token and Investigate."
},
{
"theme": "Card Draw",
"popularity_bucket": "Very Common",
"synergy_count": 17,
"total_frequency": 0,
"description": "Builds around Card Draw leveraging synergies with Loot and Wheels."
},
{
"theme": "Card Selection",
"popularity_bucket": "Niche",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Card Selection leveraging synergies with Explore and Map Token."
},
{
"theme": "Cases Matter",
"popularity_bucket": "Rare",
"synergy_count": 1,
"total_frequency": 0,
"description": "Builds around Cases Matter leveraging synergies with Enchantments Matter."
},
{
"theme": "Casualty",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Casualty leveraging synergies with Spell Copy and Sacrifice Matters."
},
{
"theme": "Caves Matter",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Caves Matter leveraging synergies with Discover and Land Types Matter."
},
{
"theme": "Celebration",
"popularity_bucket": "Rare",
"synergy_count": 1,
"total_frequency": 0,
"description": "Builds around the Celebration theme and its supporting synergies."
},
{
"theme": "Champion",
"popularity_bucket": "Rare",
"synergy_count": 2,
"total_frequency": 0,
"description": "Builds around Champion leveraging synergies with Aggro and Combat Matters."
},
{
"theme": "Changeling",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Changeling leveraging synergies with Shapeshifter Kindred and Combat Tricks."
},
{
"theme": "Channel",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Channel leveraging synergies with Spirit Kindred and Lands Matter."
},
{
"theme": "Chroma",
"popularity_bucket": "Rare",
"synergy_count": 0,
"total_frequency": 0,
"description": "Builds around the Chroma theme and its supporting synergies."
},
{
"theme": "Cipher",
"popularity_bucket": "Rare",
"synergy_count": 4,
"total_frequency": 0,
"description": "Builds around Cipher leveraging synergies with Aggro and Combat Matters."
},
{
"theme": "Clash",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Clash leveraging synergies with Warrior Kindred and Control."
},
{
"theme": "Cleave",
"popularity_bucket": "Rare",
"synergy_count": 2,
"total_frequency": 0,
"description": "Builds around Cleave leveraging synergies with Spells Matter and Spellslinger."
},
{
"theme": "Cloak",
"popularity_bucket": "Rare",
"synergy_count": 2,
"total_frequency": 0,
"description": "Builds around the Cloak theme and its supporting synergies."
},
{
"theme": "Clones",
"popularity_bucket": "Common",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Clones leveraging synergies with Populate and Myriad."
},
{
"theme": "Cohort",
"popularity_bucket": "Rare",
"synergy_count": 2,
"total_frequency": 0,
"description": "Builds around Cohort leveraging synergies with Ally Kindred."
},
{
"theme": "Collect evidence",
"popularity_bucket": "Rare",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Collect evidence leveraging synergies with Detective Kindred and Mill."
},
{
"theme": "Combat Matters",
"popularity_bucket": "Very Common",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Combat Matters leveraging synergies with Aggro and Voltron."
},
{
"theme": "Combat Tricks",
"popularity_bucket": "Very Common",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Combat Tricks leveraging synergies with Flash and Strive."
},
{
"theme": "Compleated",
"popularity_bucket": "Rare",
"synergy_count": 0,
"total_frequency": 0,
"description": "Builds around the Compleated theme and its supporting synergies."
},
{
"theme": "Conditional Draw",
"popularity_bucket": "Common",
"synergy_count": 5,
"total_frequency": 0,
"description": "Builds around Conditional Draw leveraging synergies with Start your engines! and Max speed."
}
]
}
"description_fallback_summary": null
}

1
tsconfig.tsbuildinfo Normal file
View file

@ -0,0 +1 @@
{"root":["./code/web/static/ts/app.ts","./code/web/static/ts/cardhover.ts","./code/web/static/ts/cardimages.ts","./code/web/static/ts/components.ts","./code/web/static/ts/types.ts"],"version":"5.9.3"}