Enrichment manager: merge coverage+order into one card set, fix ordering, richer rows

Addresses three pieces of UI feedback:
- Fix entity order: enrichment coverage was rendering by object-key order
  (albums first). Now sorted canonically artist → album → track via
  _emOrderEntities, used everywhere.
- Combine 'Processing order' and 'Enrichment coverage' into a single set of
  entity cards: each card shows coverage (segmented matched/not_found/pending
  bar + %) AND is the click target to pin that group to enrich first, with
  live 'Now' / pinned 'First 📌' / 'Done' states and per-worker accent. Drops
  the two redundant sections (and the old chain/stats renderers).
- Richer match rows: status stripe down the left edge (red=not found,
  amber=pending), larger rounded artwork with a gradient placeholder, parent
  context (artist/album), and a subtle slide-on-hover.

Frontend only; JS syntax clean.
This commit is contained in:
BoulderBadgeDad 2026-06-02 20:27:57 -07:00
parent f8afe56642
commit f827ddc282
2 changed files with 150 additions and 150 deletions

View file

@ -94,6 +94,15 @@ function _emEntityLabel(entity, plural) {
return plural ? base + 's' : base; return plural ? base + 's' : base;
} }
// Always present entities in the worker's real processing order, regardless of
// the order the API/object keys happen to arrive in.
const _EM_ENTITY_ORDER = { artist: 0, album: 1, track: 2 };
function _emOrderEntities(list) {
return [...new Set(list || [])].sort(
(a, b) => (_EM_ENTITY_ORDER[a] ?? 9) - (_EM_ENTITY_ORDER[b] ?? 9)
);
}
function _emEscape(s) { function _emEscape(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
@ -207,7 +216,7 @@ async function refreshEnrichmentManager(btn) {
renderEnrichmentRail(); renderEnrichmentRail();
const sel = enrichmentManagerState.selected; const sel = enrichmentManagerState.selected;
if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]); if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats(); _emRenderEntityCards();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
_emRenderPanelHeader(); _emRenderPanelHeader();
if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400); if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400);
@ -236,7 +245,7 @@ async function _emPollSelected() {
enrichmentManagerState.statuses[id] = await res.json(); enrichmentManagerState.statuses[id] = await res.json();
_emUpdateHeaderLive(); // in-place — no logo reflow/flicker _emUpdateHeaderLive(); // in-place — no logo reflow/flicker
_emUpdateRailRow(id); _emUpdateRailRow(id);
_emRenderChain(); // current-phase highlight tracks live _emRenderEntityCards();
} }
} catch (_e) { /* transient — keep last */ } } catch (_e) { /* transient — keep last */ }
} }
@ -408,12 +417,11 @@ function renderEnrichmentPanel() {
panel.innerHTML = ` panel.innerHTML = `
<div class="em-panel-header" id="em-panel-header"></div> <div class="em-panel-header" id="em-panel-header"></div>
<div class="em-banner" id="em-banner" hidden></div> <div class="em-banner" id="em-banner" hidden></div>
<div class="em-chain" id="em-chain"></div>
<div class="em-section-label em-section-label--row"> <div class="em-section-label em-section-label--row">
<span>Enrichment coverage</span> <span>Coverage &amp; processing order <span class="em-section-sub"> click a group to enrich it first</span></span>
<span class="em-coverage-overall" id="em-coverage-overall"></span> <span class="em-coverage-overall" id="em-coverage-overall"></span>
</div> </div>
<div class="em-stats" id="em-stats"></div> <div class="em-cards" id="em-cards"></div>
<div class="em-unmatched"> <div class="em-unmatched">
<div class="em-unmatched-controls" id="em-unmatched-controls"></div> <div class="em-unmatched-controls" id="em-unmatched-controls"></div>
<div class="em-bulk-bar" id="em-bulk-bar" hidden></div> <div class="em-bulk-bar" id="em-bulk-bar" hidden></div>
@ -421,56 +429,91 @@ function renderEnrichmentPanel() {
<div class="em-pager" id="em-pager"></div> <div class="em-pager" id="em-pager"></div>
</div>`; </div>`;
_emRenderPanelHeader(); _emRenderPanelHeader();
_emRenderChain(); _emRenderEntityCards();
_emRenderStats();
_emRenderUnmatchedControls(); _emRenderUnmatchedControls();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
} }
// Processing-order strip: shows the artist→album→track chain, where the worker // Combined coverage + processing-order cards. Each entity card both visualises
// currently is, and lets the user pin one group to run first. // coverage (matched/not_found/pending segmented bar + %) AND is the click target
function _emRenderChain() { // to pin that group to enrich first ('now' = live phase, '📌 first' = pinned,
const host = document.getElementById('em-chain'); // '✓ done' = nothing left). One section instead of two redundant ones.
function _emRenderEntityCards() {
const host = document.getElementById('em-cards');
if (!host) return; if (!host) return;
const id = enrichmentManagerState.selected; const id = enrichmentManagerState.selected;
const supported = (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types) const bd = enrichmentManagerState.breakdown;
|| (enrichmentManagerState.breakdown && Object.keys(enrichmentManagerState.breakdown)) const supported = _emOrderEntities(
|| ['artist']; (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types)
|| (bd && Object.keys(bd)) || ['artist']
);
if (!bd) {
host.innerHTML = supported.map(() => `
<div class="em-card em-skel-card">
<div class="em-skel em-skel-line" style="width:45%"></div>
<div class="em-skel em-skel-bar"></div>
<div class="em-skel em-skel-line" style="width:70%"></div>
</div>`).join('');
return;
}
const status = enrichmentManagerState.statuses[id]; const status = enrichmentManagerState.statuses[id];
const phase = _emCurrentPhase(status); const phase = _emCurrentPhase(status);
const pinned = enrichmentManagerState.priority; const pinned = enrichmentManagerState.priority;
const bd = enrichmentManagerState.breakdown || {};
const glyphs = { artist: '🎤', album: '💿', track: '🎵' }; const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
const steps = supported.map((e, i) => { host.innerHTML = supported.map(e => {
const d = bd[e] || {}; const d = bd[e] || {};
const pending = (d.pending || 0) + (d.not_found || 0); const total = d.total || 0, matched = d.matched || 0, nf = d.not_found || 0, pend = d.pending || 0;
const isCurrent = phase === e; const pct = total ? Math.round((matched / total) * 100) : 0;
const isPinned = pinned === e; const seg = (n) => (total ? (n / total) * 100 : 0);
const isDone = bd[e] && pending === 0; const isPinned = pinned === e, isCurrent = phase === e, isDone = total > 0 && (pend + nf) === 0;
const cls = ['em-step', const cls = ['em-card',
isPinned ? 'em-step--pinned' : '', isPinned ? 'em-card--pinned' : '',
isCurrent ? 'em-step--current' : '', isCurrent ? 'em-card--current' : '',
isDone ? 'em-step--done' : ''].filter(Boolean).join(' '); isDone ? 'em-card--done' : ''].filter(Boolean).join(' ');
const note = isPinned ? 'first' : (isCurrent ? 'now' : (isDone ? 'done' : `${pending.toLocaleString()} left`)); const badge = isPinned
const arrow = i < supported.length - 1 ? '<span class="em-step-arrow">→</span>' : ''; ? '<span class="em-card-badge em-card-badge--pin">📌 First</span>'
: isCurrent
? '<span class="em-card-badge em-card-badge--now">● Now</span>'
: isDone
? '<span class="em-card-badge em-card-badge--done">✓ Done</span>'
: `<span class="em-card-badge">${(pend + nf).toLocaleString()} left</span>`;
return ` return `
<button class="${cls}" title="Process ${_emEntityLabel(e, true).toLowerCase()} first" <button class="${cls}" title="${isPinned ? 'Pinned first — click for automatic order' : 'Process ' + _emEntityLabel(e, true).toLowerCase() + ' first'}"
onclick="setEnrichmentPriority('${isPinned ? '' : e}')"> onclick="setEnrichmentPriority('${isPinned ? '' : e}')">
<span class="em-step-glyph">${glyphs[e] || '•'}</span> <div class="em-card-top">
<span class="em-step-name">${_emEntityLabel(e, true)}</span> <span class="em-card-glyph">${glyphs[e] || '•'}</span>
<span class="em-step-note">${note}</span> <span class="em-card-title">${_emEntityLabel(e, true)}</span>
</button>${arrow}`; ${badge}
<span class="em-card-pct">${pct}<span class="em-stat-pct-sym">%</span></span>
</div>
<div class="em-seg" title="${matched.toLocaleString()} matched · ${nf.toLocaleString()} not found · ${pend.toLocaleString()} pending">
<div class="em-seg-fill em-seg--matched" data-pct="${seg(matched)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--nf" data-pct="${seg(nf)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--pend" data-pct="${seg(pend)}" style="width:0%"></div>
</div>
<div class="em-stat-legend">
<span class="em-leg em-leg--matched" title="matched"><i></i>${matched.toLocaleString()}</span>
<span class="em-leg em-leg--nf" title="not found"><i></i>${nf.toLocaleString()}</span>
<span class="em-leg em-leg--pend" title="pending"><i></i>${pend.toLocaleString()}</span>
</div>
</button>`;
}).join(''); }).join('');
host.innerHTML = ` // Overall matched coverage across every entity type.
<div class="em-chain-head"> const overall = document.getElementById('em-coverage-overall');
<span class="em-section-label">Processing order</span> if (overall) {
<span class="em-chain-hint">${pinned let m = 0, t = 0;
? `Pinned <strong>${_emEntityLabel(pinned, true)}</strong> first · click again for auto` Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; });
: 'Click a group to process it first'}</span> const pct = t ? Math.round((m / t) * 100) : 0;
</div> overall.innerHTML = t ? `<strong>${pct}%</strong> matched · ${m.toLocaleString()} of ${t.toLocaleString()}` : '';
<div class="em-chain-flow">${steps}</div>`; }
requestAnimationFrame(() => {
host.querySelectorAll('.em-seg-fill').forEach(el => { el.style.width = `${el.dataset.pct || 0}%`; });
});
} }
async function setEnrichmentPriority(entity) { async function setEnrichmentPriority(entity) {
@ -491,7 +534,7 @@ async function setEnrichmentPriority(entity) {
if (!enrichmentManagerState.priority) { if (!enrichmentManagerState.priority) {
showToast(`${name} back to automatic order`, 'success'); showToast(`${name} back to automatic order`, 'success');
_emRenderChain(); _emRenderEntityCards();
return; return;
} }
@ -515,10 +558,9 @@ async function setEnrichmentPriority(entity) {
? `${name} will process all ${label} first · re-queued ${reset.toLocaleString()} previously-failed` ? `${name} will process all ${label} first · re-queued ${reset.toLocaleString()} previously-failed`
: `${name} will process ${label} first`, 'success'); : `${name} will process ${label} first`, 'success');
// Counts changed (failed -> pending) — refresh stats, chain and list. // Counts changed (failed -> pending) — refresh cards + list.
await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]); await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]);
_emRenderStats(); _emRenderEntityCards();
_emRenderChain();
_emRenderUnmatchedControls(); _emRenderUnmatchedControls();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
} catch (_e) { } catch (_e) {
@ -622,69 +664,6 @@ function _emHumanDuration(seconds) {
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`; return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
} }
function _emRenderStats() {
const host = document.getElementById('em-stats');
if (!host) return;
const bd = enrichmentManagerState.breakdown;
if (!bd) {
// Skeleton cards (count unknown yet — 3 covers the common case).
host.innerHTML = Array.from({ length: 3 }, () => `
<div class="em-stat-card em-skel-card">
<div class="em-skel em-skel-line" style="width:40%"></div>
<div class="em-skel em-skel-bar"></div>
<div class="em-skel em-skel-line" style="width:75%"></div>
</div>`).join('');
return;
}
const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
host.innerHTML = Object.keys(bd).map(entity => {
const d = bd[entity] || {};
const total = d.total || 0;
const matched = d.matched || 0;
const notFound = d.not_found || 0;
const pending = d.pending || 0;
const pct = total ? Math.round((matched / total) * 100) : 0;
const seg = (n) => (total ? (n / total) * 100 : 0);
return `
<div class="em-stat-card">
<div class="em-stat-head">
<span class="em-stat-title"><span class="em-stat-ico">${glyphs[entity] || '•'}</span>${_emEntityLabel(entity, true)}</span>
<span class="em-stat-pct">${pct}<span class="em-stat-pct-sym">%</span></span>
</div>
<div class="em-seg" title="${matched.toLocaleString()} matched · ${notFound.toLocaleString()} not found · ${pending.toLocaleString()} pending">
<div class="em-seg-fill em-seg--matched" data-pct="${seg(matched)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--nf" data-pct="${seg(notFound)}" style="width:0%"></div>
<div class="em-seg-fill em-seg--pend" data-pct="${seg(pending)}" style="width:0%"></div>
</div>
<div class="em-stat-legend">
<span class="em-leg em-leg--matched"><i></i>${matched.toLocaleString()} matched</span>
<span class="em-leg em-leg--nf"><i></i>${notFound.toLocaleString()} missed</span>
<span class="em-leg em-leg--pend"><i></i>${pending.toLocaleString()} pending</span>
</div>
</div>`;
}).join('');
// Overall matched coverage across every entity type (relocated here from
// the hero per the refined-banner header).
const overall = document.getElementById('em-coverage-overall');
if (overall) {
let m = 0, t = 0;
Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; });
const pct = t ? Math.round((m / t) * 100) : 0;
overall.innerHTML = t
? `<strong>${pct}%</strong> matched · ${m.toLocaleString()} of ${t.toLocaleString()}`
: '';
}
// Animate the segments in from 0 on the next frame (CSS transition does the rest).
requestAnimationFrame(() => {
host.querySelectorAll('.em-seg-fill').forEach(el => {
el.style.width = `${el.dataset.pct || 0}%`;
});
});
}
function _emRenderUnmatchedControls() { function _emRenderUnmatchedControls() {
const host = document.getElementById('em-unmatched-controls'); const host = document.getElementById('em-unmatched-controls');
if (!host) return; if (!host) return;
@ -774,8 +753,9 @@ function _emRenderUnmatchedList() {
? `<span class="em-row-parent" title="${_emEscape(item.parent)}">· ${_emEscape(item.parent)}</span>` ? `<span class="em-row-parent" title="${_emEscape(item.parent)}">· ${_emEscape(item.parent)}</span>`
: ''; : '';
const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : ''; const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : '';
const rowCls = item.status === 'not_found' ? 'em-row em-row--nf' : 'em-row em-row--pend';
return ` return `
<div class="em-row"> <div class="${rowCls}">
<input type="checkbox" class="em-row-check" ${checked} <input type="checkbox" class="em-row-check" ${checked}
aria-label="Select ${safeName}" aria-label="Select ${safeName}"
onchange="toggleEnrichmentRowSelect('${safeId}', this.checked)"> onchange="toggleEnrichmentRowSelect('${safeId}', this.checked)">
@ -857,7 +837,7 @@ async function retrySelectedEnrichment(btn) {
showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error'); showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error');
enrichmentManagerState.selectedItems.clear(); enrichmentManagerState.selectedItems.clear();
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats(); _emRenderEntityCards();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
} }
@ -944,7 +924,7 @@ async function retryEnrichmentItem(service, entityType, entityId, btn) {
if (res.ok && data.success) { if (res.ok && data.success) {
showToast('Re-queued for enrichment', 'success'); showToast('Re-queued for enrichment', 'success');
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats(); _emRenderEntityCards();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
} else { } else {
showToast(data.error || 'Failed to re-queue', 'error'); showToast(data.error || 'Failed to re-queue', 'error');
@ -976,7 +956,7 @@ async function retryAllFailedEnrichment(btn) {
showToast(`Re-queued ${(data.reset || 0).toLocaleString()} item(s)`, 'success'); showToast(`Re-queued ${(data.reset || 0).toLocaleString()} item(s)`, 'success');
enrichmentManagerState.page = 0; enrichmentManagerState.page = 0;
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats(); _emRenderEntityCards();
_emRenderUnmatchedControls(); _emRenderUnmatchedControls();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
} else { } else {
@ -1089,7 +1069,7 @@ async function _emApplyMatch(entityType, entityId, service, serviceId, overlay)
// Refresh the manager's stats + list for the *selected* worker. // Refresh the manager's stats + list for the *selected* worker.
const sel = enrichmentManagerState.selected; const sel = enrichmentManagerState.selected;
await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]); await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats(); _emRenderEntityCards();
_emRenderUnmatchedList(); _emRenderUnmatchedList();
} else { } else {
showToast(data.error || 'Failed to match', 'error'); showToast(data.error || 'Failed to match', 'error');

View file

@ -64978,16 +64978,34 @@ body.reduce-effects .dash-card::after {
.em-unmatched-list { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-right: 4px; } .em-unmatched-list { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-right: 4px; }
.em-row { .em-row {
display: flex; align-items: center; gap: 12px; padding: 9px 12px; display: flex; align-items: center; gap: 13px; padding: 10px 14px 10px 16px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; background: linear-gradient(100deg, rgba(255,255,255,0.045), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.07); border-radius: 12px;
position: relative; overflow: hidden;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
} }
.em-row:hover { background: rgba(255,255,255,0.06); } /* status stripe down the left edge */
.em-row-img { width: 42px; height: 42px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; } .em-row::before {
.em-row-img--ph { display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); font-size: 18px; } content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: rgba(255,255,255,0.15);
}
.em-row--nf::before { background: #e0586b; }
.em-row--pend::before { background: #f0c060; }
.em-row:hover {
transform: translateX(2px);
background: linear-gradient(100deg, rgba(255,255,255,0.07), rgba(255,255,255,0.03));
border-color: rgba(var(--em-accent-rgb, 255,255,255), 0.22);
}
.em-row-img { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; flex: 0 0 auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
.em-row-img--ph { display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(var(--em-accent-rgb, 99,102,241), 0.3), rgba(255,255,255,0.06));
color: rgba(255,255,255,0.55); font-size: 20px; }
.em-row-info { flex: 1 1 auto; min-width: 0; } .em-row-info { flex: 1 1 auto; min-width: 0; }
.em-row-name { font-size: 14px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .em-row-name { font-size: 14.5px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.em-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; font-size: 11px; } .em-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 4px; font-size: 11px; }
.em-row-actions { display: flex; gap: 6px; flex: 0 0 auto; } .em-row-actions { display: flex; gap: 6px; flex: 0 0 auto; opacity: 0.85; transition: opacity 0.15s ease; }
.em-row:hover .em-row-actions { opacity: 1; }
.em-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,0.55); font-size: 15px; } .em-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,0.55); font-size: 15px; }
.em-empty-emoji { font-size: 38px; margin-bottom: 10px; opacity: 0.85; } .em-empty-emoji { font-size: 38px; margin-bottom: 10px; opacity: 0.85; }
.em-pager { display: flex; align-items: center; justify-content: center; gap: 14px; flex: 0 0 auto; padding-top: 4px; font-size: 12px; } .em-pager { display: flex; align-items: center; justify-content: center; gap: 14px; flex: 0 0 auto; padding-top: 4px; font-size: 12px; }
@ -65093,39 +65111,41 @@ body.em-scroll-lock { overflow: hidden; }
.em-ph-name-sub { font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.45); } .em-ph-name-sub { font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.45); }
.em-hero .em-ph-actions { z-index: 1; } .em-hero .em-ph-actions { z-index: 1; }
/* Processing-order chain strip */ /* Combined coverage + processing-order cards (click to enrich a group first) */
.em-chain { display: flex; flex-direction: column; gap: 9px; flex: 0 0 auto; } .em-section-sub { font-weight: 500; text-transform: none; letter-spacing: 0; color: rgba(255,255,255,0.35); font-size: 11px; }
.em-chain-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .em-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); gap: 12px; flex: 0 0 auto; }
.em-chain-hint { font-size: 11.5px; color: rgba(255,255,255,0.4); } .em-card {
.em-chain-hint strong { color: rgb(var(--em-accent-rgb, 129,140,248)); } display: flex; flex-direction: column; gap: 10px; text-align: left; cursor: pointer;
.em-chain-flow { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; } padding: 14px; border-radius: 14px; position: relative; overflow: hidden;
.em-step { background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
flex: 1 1 0; min-width: 120px; border: 1.5px solid rgba(255,255,255,0.08);
display: flex; flex-direction: column; align-items: flex-start; gap: 2px; transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
padding: 10px 13px; border-radius: 12px; cursor: pointer; text-align: left;
background: rgba(255,255,255,0.035); border: 1.5px solid rgba(255,255,255,0.08);
transition: all 0.2s ease; position: relative; overflow: hidden;
} }
.em-step:hover { background: rgba(255,255,255,0.06); border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.4); transform: translateY(-1px); } .em-card:hover {
.em-step-glyph { font-size: 16px; } transform: translateY(-2px);
.em-step-name { font-size: 13px; font-weight: 700; color: #fff; } border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.45);
.em-step-note { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); } background: linear-gradient(160deg, rgba(255,255,255,0.09), rgba(255,255,255,0.03));
.em-step-arrow { align-self: center; color: rgba(255,255,255,0.25); font-size: 16px; }
/* Pinned = will run first */
.em-step--pinned {
background: rgba(var(--em-accent-rgb, 99,102,241), 0.16);
border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.6);
} }
.em-step--pinned .em-step-note { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; } .em-card-top { display: flex; align-items: center; gap: 8px; }
.em-step--pinned::after { content: '📌'; position: absolute; top: 6px; right: 8px; font-size: 11px; } .em-card-glyph { font-size: 15px; }
/* Current = being processed now */ .em-card-title { font-size: 12.5px; font-weight: 800; color: #fff; text-transform: uppercase; letter-spacing: 0.5px; }
.em-step--current .em-step-name::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 6px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; animation: em-glow-pulse 1.6s ease-in-out infinite; vertical-align: middle; } .em-card-pct { margin-left: auto; font-size: 21px; font-weight: 800; color: #fff; line-height: 1; }
.em-step--current { border-color: rgba(74,222,128,0.45); } .em-card-badge { font-size: 9.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 7px; border-radius: 999px; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.55); white-space: nowrap; }
.em-step--current .em-step-note { color: #4ade80; } .em-card-badge--pin { background: rgba(var(--em-accent-rgb, 99,102,241), 0.28); color: #fff; }
/* Done = nothing left */ .em-card-badge--now { background: rgba(74,222,128,0.2); color: #4ade80; }
.em-step--done .em-step-glyph { opacity: 0.5; } .em-card-badge--done { background: rgba(74,222,128,0.14); color: rgba(74,222,128,0.85); }
.em-step--done .em-step-note { color: rgba(74,222,128,0.7); } .em-card .em-stat-legend { gap: 12px; margin-top: 0; }
@media (prefers-reduced-motion: reduce) { .em-step--current .em-step-name::after { animation: none; } } .em-card .em-leg { font-size: 11.5px; color: rgba(255,255,255,0.7); }
/* States */
.em-card--pinned {
border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.65);
background: linear-gradient(160deg, rgba(var(--em-accent-rgb, 99,102,241), 0.16), rgba(255,255,255,0.02));
box-shadow: 0 0 0 1px rgba(var(--em-accent-rgb, 99,102,241), 0.25), 0 8px 24px rgba(var(--em-accent-rgb, 99,102,241), 0.15);
}
.em-card--current { border-color: rgba(74,222,128,0.4); }
.em-card--current .em-card-badge--now { animation: em-glow-pulse 1.8s ease-in-out infinite; }
.em-card--done { opacity: 0.74; }
@media (prefers-reduced-motion: reduce) { .em-card--current .em-card-badge--now { animation: none; } }
/* Section label as a row (coverage % relocated here from the hero) */ /* Section label as a row (coverage % relocated here from the hero) */
.em-section-label--row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .em-section-label--row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }