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;
}
// 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) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
@ -207,7 +216,7 @@ async function refreshEnrichmentManager(btn) {
renderEnrichmentRail();
const sel = enrichmentManagerState.selected;
if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats();
_emRenderEntityCards();
_emRenderUnmatchedList();
_emRenderPanelHeader();
if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400);
@ -236,7 +245,7 @@ async function _emPollSelected() {
enrichmentManagerState.statuses[id] = await res.json();
_emUpdateHeaderLive(); // in-place — no logo reflow/flicker
_emUpdateRailRow(id);
_emRenderChain(); // current-phase highlight tracks live
_emRenderEntityCards();
}
} catch (_e) { /* transient — keep last */ }
}
@ -408,12 +417,11 @@ function renderEnrichmentPanel() {
panel.innerHTML = `
<div class="em-panel-header" id="em-panel-header"></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">
<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>
</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-controls" id="em-unmatched-controls"></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>`;
_emRenderPanelHeader();
_emRenderChain();
_emRenderStats();
_emRenderEntityCards();
_emRenderUnmatchedControls();
_emRenderUnmatchedList();
}
// Processing-order strip: shows the artist→album→track chain, where the worker
// currently is, and lets the user pin one group to run first.
function _emRenderChain() {
const host = document.getElementById('em-chain');
// Combined coverage + processing-order cards. Each entity card both visualises
// coverage (matched/not_found/pending segmented bar + %) AND is the click target
// to pin that group to enrich first ('now' = live phase, '📌 first' = pinned,
// '✓ done' = nothing left). One section instead of two redundant ones.
function _emRenderEntityCards() {
const host = document.getElementById('em-cards');
if (!host) return;
const id = enrichmentManagerState.selected;
const supported = (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types)
|| (enrichmentManagerState.breakdown && Object.keys(enrichmentManagerState.breakdown))
|| ['artist'];
const bd = enrichmentManagerState.breakdown;
const supported = _emOrderEntities(
(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 phase = _emCurrentPhase(status);
const pinned = enrichmentManagerState.priority;
const bd = enrichmentManagerState.breakdown || {};
const glyphs = { artist: '🎤', album: '💿', track: '🎵' };
const steps = supported.map((e, i) => {
host.innerHTML = supported.map(e => {
const d = bd[e] || {};
const pending = (d.pending || 0) + (d.not_found || 0);
const isCurrent = phase === e;
const isPinned = pinned === e;
const isDone = bd[e] && pending === 0;
const cls = ['em-step',
isPinned ? 'em-step--pinned' : '',
isCurrent ? 'em-step--current' : '',
isDone ? 'em-step--done' : ''].filter(Boolean).join(' ');
const note = isPinned ? 'first' : (isCurrent ? 'now' : (isDone ? 'done' : `${pending.toLocaleString()} left`));
const arrow = i < supported.length - 1 ? '<span class="em-step-arrow">→</span>' : '';
const total = d.total || 0, matched = d.matched || 0, nf = d.not_found || 0, pend = d.pending || 0;
const pct = total ? Math.round((matched / total) * 100) : 0;
const seg = (n) => (total ? (n / total) * 100 : 0);
const isPinned = pinned === e, isCurrent = phase === e, isDone = total > 0 && (pend + nf) === 0;
const cls = ['em-card',
isPinned ? 'em-card--pinned' : '',
isCurrent ? 'em-card--current' : '',
isDone ? 'em-card--done' : ''].filter(Boolean).join(' ');
const badge = isPinned
? '<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 `
<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}')">
<span class="em-step-glyph">${glyphs[e] || '•'}</span>
<span class="em-step-name">${_emEntityLabel(e, true)}</span>
<span class="em-step-note">${note}</span>
</button>${arrow}`;
<div class="em-card-top">
<span class="em-card-glyph">${glyphs[e] || '•'}</span>
<span class="em-card-title">${_emEntityLabel(e, true)}</span>
${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('');
host.innerHTML = `
<div class="em-chain-head">
<span class="em-section-label">Processing order</span>
<span class="em-chain-hint">${pinned
? `Pinned <strong>${_emEntityLabel(pinned, true)}</strong> first · click again for auto`
: 'Click a group to process it first'}</span>
</div>
<div class="em-chain-flow">${steps}</div>`;
// Overall matched coverage across every entity type.
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()}` : '';
}
requestAnimationFrame(() => {
host.querySelectorAll('.em-seg-fill').forEach(el => { el.style.width = `${el.dataset.pct || 0}%`; });
});
}
async function setEnrichmentPriority(entity) {
@ -491,7 +534,7 @@ async function setEnrichmentPriority(entity) {
if (!enrichmentManagerState.priority) {
showToast(`${name} back to automatic order`, 'success');
_emRenderChain();
_emRenderEntityCards();
return;
}
@ -515,10 +558,9 @@ async function setEnrichmentPriority(entity) {
? `${name} will process all ${label} first · re-queued ${reset.toLocaleString()} previously-failed`
: `${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()]);
_emRenderStats();
_emRenderChain();
_emRenderEntityCards();
_emRenderUnmatchedControls();
_emRenderUnmatchedList();
} catch (_e) {
@ -622,69 +664,6 @@ function _emHumanDuration(seconds) {
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() {
const host = document.getElementById('em-unmatched-controls');
if (!host) return;
@ -774,8 +753,9 @@ function _emRenderUnmatchedList() {
? `<span class="em-row-parent" title="${_emEscape(item.parent)}">· ${_emEscape(item.parent)}</span>`
: '';
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 `
<div class="em-row">
<div class="${rowCls}">
<input type="checkbox" class="em-row-check" ${checked}
aria-label="Select ${safeName}"
onchange="toggleEnrichmentRowSelect('${safeId}', this.checked)">
@ -857,7 +837,7 @@ async function retrySelectedEnrichment(btn) {
showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error');
enrichmentManagerState.selectedItems.clear();
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats();
_emRenderEntityCards();
_emRenderUnmatchedList();
}
@ -944,7 +924,7 @@ async function retryEnrichmentItem(service, entityType, entityId, btn) {
if (res.ok && data.success) {
showToast('Re-queued for enrichment', 'success');
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats();
_emRenderEntityCards();
_emRenderUnmatchedList();
} else {
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');
enrichmentManagerState.page = 0;
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
_emRenderStats();
_emRenderEntityCards();
_emRenderUnmatchedControls();
_emRenderUnmatchedList();
} else {
@ -1089,7 +1069,7 @@ async function _emApplyMatch(entityType, entityId, service, serviceId, overlay)
// Refresh the manager's stats + list for the *selected* worker.
const sel = enrichmentManagerState.selected;
await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]);
_emRenderStats();
_emRenderEntityCards();
_emRenderUnmatchedList();
} else {
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-row {
display: flex; align-items: center; gap: 12px; padding: 9px 12px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px;
display: flex; align-items: center; gap: 13px; padding: 10px 14px 10px 16px;
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); }
.em-row-img { width: 42px; height: 42px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; }
.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; }
/* status stripe down the left edge */
.em-row::before {
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-name { font-size: 14px; 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-actions { display: flex; gap: 6px; flex: 0 0 auto; }
.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: 4px; font-size: 11px; }
.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-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; }
@ -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-hero .em-ph-actions { z-index: 1; }
/* Processing-order chain strip */
.em-chain { display: flex; flex-direction: column; gap: 9px; flex: 0 0 auto; }
.em-chain-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.em-chain-hint { font-size: 11.5px; color: rgba(255,255,255,0.4); }
.em-chain-hint strong { color: rgb(var(--em-accent-rgb, 129,140,248)); }
.em-chain-flow { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; }
.em-step {
flex: 1 1 0; min-width: 120px;
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
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;
/* Combined coverage + processing-order cards (click to enrich a group first) */
.em-section-sub { font-weight: 500; text-transform: none; letter-spacing: 0; color: rgba(255,255,255,0.35); font-size: 11px; }
.em-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); gap: 12px; flex: 0 0 auto; }
.em-card {
display: flex; flex-direction: column; gap: 10px; text-align: left; cursor: pointer;
padding: 14px; border-radius: 14px; position: relative; overflow: hidden;
background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
border: 1.5px solid rgba(255,255,255,0.08);
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.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-step-glyph { font-size: 16px; }
.em-step-name { font-size: 13px; font-weight: 700; color: #fff; }
.em-step-note { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); }
.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-card:hover {
transform: translateY(-2px);
border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.45);
background: linear-gradient(160deg, rgba(255,255,255,0.09), rgba(255,255,255,0.03));
}
.em-step--pinned .em-step-note { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; }
.em-step--pinned::after { content: '📌'; position: absolute; top: 6px; right: 8px; font-size: 11px; }
/* Current = being processed now */
.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-step--current { border-color: rgba(74,222,128,0.45); }
.em-step--current .em-step-note { color: #4ade80; }
/* Done = nothing left */
.em-step--done .em-step-glyph { opacity: 0.5; }
.em-step--done .em-step-note { color: rgba(74,222,128,0.7); }
@media (prefers-reduced-motion: reduce) { .em-step--current .em-step-name::after { animation: none; } }
.em-card-top { display: flex; align-items: center; gap: 8px; }
.em-card-glyph { font-size: 15px; }
.em-card-title { font-size: 12.5px; font-weight: 800; color: #fff; text-transform: uppercase; letter-spacing: 0.5px; }
.em-card-pct { margin-left: auto; font-size: 21px; font-weight: 800; color: #fff; line-height: 1; }
.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-card-badge--pin { background: rgba(var(--em-accent-rgb, 99,102,241), 0.28); color: #fff; }
.em-card-badge--now { background: rgba(74,222,128,0.2); color: #4ade80; }
.em-card-badge--done { background: rgba(74,222,128,0.14); color: rgba(74,222,128,0.85); }
.em-card .em-stat-legend { gap: 12px; margin-top: 0; }
.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) */
.em-section-label--row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }