Dashboard: equalizer-bar redesign for Enrichment Services panel
The rate monitor on the dashboard used a 10-column grid of circular SVG speedometers. With 11 services configured (Amazon was the straw), the grid produced 10-in-row-1 + 1-orphan-in-row-2, breaking the dashboard's tile symmetry. Speedometers also wasted ~80% of their pixels on empty arc — most services sit at 0 cpm most of the time, so the row visually read as a wall of empty gauges. Replaced with a VU-meter / equalizer row: one vertical capsule per service, brand-color gradient filling from the bottom, bar height tracks ``calls/min ÷ limit``. Music-app native aesthetic, fits the existing accent-heavy glassy vibe, and symmetric by design at any service count — services slot into the flex row. Visual details - 4% sliver floor on idle bars so the row reads as "everything alive" instead of "8 dead gauges" — vibe over literal zero - Continuous shimmer scan when worker is running (vertical wash) - Slow breathing pulse on idle bars - Red gradient + faster pulse when rate-limited - White-hot peak tip glows in the service's accent color - Status pill below each bar (Running pulses green, Paused amber) - Big count number floats top-center of the track Behavior - Click any bar opens the same detail modal the speedometer used — no data-flow changes, no API changes, drop-in visual swap. - Renderer auto-detects the dashboard context (data-card="enrichment") and routes through the equalizer path; legacy speedometer code still ships for any non-dashboard mount. - Responsive: tightens at laptop/tablet breakpoints, wraps to 5-per-row on phones.
This commit is contained in:
parent
17607c2d83
commit
74dcafd6e9
3 changed files with 416 additions and 26 deletions
|
|
@ -40,6 +40,19 @@ function _handleRateMonitorUpdate(data) {
|
|||
const grid = document.getElementById('rate-monitor-grid');
|
||||
if (!grid) return;
|
||||
|
||||
// The dashboard rate monitor uses the equalizer-bar visual — a
|
||||
// vertical-bar VU-meter row that fits any service count without
|
||||
// an orphan grid cell. Detail page / mobile breakpoints keep the
|
||||
// legacy speedometer card markup so existing CSS still applies.
|
||||
const useEqualizer = grid.classList.contains('rate-monitor-grid--equalizer')
|
||||
|| grid.closest('[data-card="enrichment"]') !== null;
|
||||
|
||||
if (useEqualizer) {
|
||||
grid.classList.add('rate-monitor-grid--equalizer');
|
||||
_renderEqualizerBars(grid, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!grid.children.length) {
|
||||
for (const svc of _RATE_GAUGE_SERVICES) {
|
||||
const div = document.createElement('div');
|
||||
|
|
@ -143,6 +156,89 @@ function _handleRateMonitorUpdate(data) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Equalizer-bar renderer (dashboard) ─────────────────────────────────
|
||||
//
|
||||
// VU-meter aesthetic: one vertical bar per service. Bar height = current
|
||||
// rate / limit. Service brand color fades up the bar with an animated
|
||||
// glow at the tip when active. Click opens the same detail modal the
|
||||
// speedometer used. Symmetric by design — any service count fits one
|
||||
// flex row regardless of viewport.
|
||||
|
||||
function _renderEqualizerBars(grid, data) {
|
||||
if (!grid.children.length) {
|
||||
for (const svc of _RATE_GAUGE_SERVICES) {
|
||||
const accent = _RATE_GAUGE_COLORS[svc] || '#888';
|
||||
const label = _RATE_GAUGE_LABELS[svc] || svc;
|
||||
const bar = document.createElement('button');
|
||||
bar.type = 'button';
|
||||
bar.className = 'rate-eq';
|
||||
bar.id = `rate-eq-${svc}`;
|
||||
bar.style.setProperty('--eq-accent', accent);
|
||||
bar.setAttribute('aria-label', `${label} rate detail`);
|
||||
bar.onclick = () => _openRateModal(svc);
|
||||
bar.innerHTML = `
|
||||
<div class="rate-eq-track">
|
||||
<div class="rate-eq-ticks"></div>
|
||||
<div class="rate-eq-fill">
|
||||
<div class="rate-eq-shimmer"></div>
|
||||
<div class="rate-eq-tip"></div>
|
||||
</div>
|
||||
<div class="rate-eq-value">0</div>
|
||||
</div>
|
||||
<div class="rate-eq-meta">
|
||||
<span class="rate-eq-state" data-status="stopped">
|
||||
<span class="rate-eq-state-dot"></span>
|
||||
<span class="rate-eq-state-text">Stopped</span>
|
||||
</span>
|
||||
<span class="rate-eq-name">${label}</span>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
for (const svc of _RATE_GAUGE_SERVICES) {
|
||||
const d = data[svc];
|
||||
if (!d) continue;
|
||||
_rateMonitorState[svc] = d;
|
||||
const bar = document.getElementById(`rate-eq-${svc}`);
|
||||
if (!bar) continue;
|
||||
|
||||
const value = d.cpm || 0;
|
||||
const max = d.limit || 60;
|
||||
// Clamp the visual fill to [4%, 100%]. The 4% floor lets idle
|
||||
// bars still show a sliver of accent so the row reads as
|
||||
// present-but-quiet instead of empty — critical for the
|
||||
// "everything alive" vibe; an actual zero would make most
|
||||
// services disappear most of the time.
|
||||
const pct = Math.max(0.04, Math.min(value / max, 1));
|
||||
const worker = d.worker || {};
|
||||
const wStatus = worker.status || 'stopped';
|
||||
const isRateLimited = d.rate_limited === true;
|
||||
|
||||
const fill = bar.querySelector('.rate-eq-fill');
|
||||
if (fill) fill.style.height = `${pct * 100}%`;
|
||||
const val = bar.querySelector('.rate-eq-value');
|
||||
if (val) val.textContent = Math.round(value);
|
||||
|
||||
const state = bar.querySelector('.rate-eq-state');
|
||||
if (state) {
|
||||
state.dataset.status = wStatus;
|
||||
const text = state.querySelector('.rate-eq-state-text');
|
||||
if (text) text.textContent = _workerStatusLabel(wStatus, worker);
|
||||
}
|
||||
|
||||
// Danger threshold uses the REAL (unclamped) ratio so the
|
||||
// 4% visual floor for idle bars doesn't push everything into
|
||||
// a permanent green state — the threshold reads true rate.
|
||||
const realPct = max > 0 ? value / max : 0;
|
||||
bar.classList.toggle('danger', realPct > 0.8 || isRateLimited);
|
||||
bar.classList.toggle('active', value > 0 || wStatus === 'running');
|
||||
bar.classList.toggle('rate-limited', isRateLimited);
|
||||
}
|
||||
}
|
||||
|
||||
function _workerStatusLabel(status, worker) {
|
||||
if (status === 'not_configured') return 'Not configured';
|
||||
if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused';
|
||||
|
|
|
|||
|
|
@ -3415,6 +3415,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.6.3': [
|
||||
{ unreleased: true },
|
||||
{ title: 'Dashboard: equalizer-bar redesign for the Enrichment Services panel', desc: 'the enrichment rate monitor on the dashboard used a 10-column grid of circular speedometers, which (a) lost symmetry the moment a new service was added — 11 services meant 10 in one row and a lonely orphan in the next, (b) wasted ~80% of pixels on empty gauge arcs showing "0" most of the time. Replaced with a VU-meter / equalizer-bar row: each service is a vertical capsule with the service\'s brand-color fill height tracking calls/min, a continuous shimmer scan + glowing peak tip when active, a slow breathing animation when idle, and a red pulsing variant when rate-limited. Symmetric by design at any service count — the row uses flex with even gaps so new services just slot in. Bar tip + value text adapt color to the service\'s accent. Status pill below each bar (Running / Idle / Paused / Stopped) with its own pulsing dot. Click any bar to open the same detail modal the speedometer used; nothing changed about the underlying data flow.', page: 'dashboard' },
|
||||
{ title: 'Auto-Sync: fix ListenBrainz pipelines stuck on "Refreshing:" for 5+ minutes', desc: 'Auto-Sync pipeline runs against a ListenBrainz playlist (Weekly Jams / Weekly Exploration / Top Discoveries / etc.) would sit on "Refreshing: \'<playlist name>\'" for minutes with zero UI updates before anything else happened. Two real bugs ganging up: (1) the refresh path ran the LB matching engine on every track to produce matched_data — 5+ minutes blocking with no progress emission — and Phase 2 of the pipeline then ran the SAME matching engine on the same tracks via the discovery worker. So LB tracks discovered twice, the first time silently. (2) ListenBrainz manager only exposed an `update_all_playlists` entry-point — refreshing one playlist re-pulled all 12+ cached LB playlists\' details from the API even though we only cared about one. Pipeline now skips the refresh-side discovery (Phase 2 handles it with progress emits every 3s — same pattern that already works for Spotify); refresh uses a new targeted `LBManager.refresh_playlist(mbid)` that hits just the requested playlist. Also killed a silent `except Exception: pass` in the LB adapter that was masking real API failures as stale-cache hits — refresh errors now log with traceback and surface to the run-history error tally. Pinned with 12 new unit tests.', page: 'automations' },
|
||||
{ title: 'Wishlist: harden Spotify backfill so a poisoned track_number can\'t mask a lean album', desc: 'follow-up to the earlier wishlist import-path work. residual per-track wishlist downloads (single tracks from different albums, falling below the album-bundle threshold) were producing folders without a year subfolder whenever the underlying wishlist row had a track_number=1 from an older default — the candidate dispatcher\'s "fall back to Spotify API" branch was gated entirely on track_number being missing, but the same API call was also the only thing that hydrated the lean album_context (release_date / total_tracks / cover art) when the original discovery match came from Deezer\'s search endpoint which doesn\'t carry those fields. so any row whose track_number happened to look "filled" (1 from a default) would short-circuit the API call and the year disappeared from the folder path even though the API knew it. split the two concerns: track_number resolution keeps its track_info → track object → API precedence, but album hydration now runs whenever release_date or total_tracks are missing regardless of where track_number came from. one network round-trip still serves both. lifted into core/downloads/track_metadata_backfill.py with 24 unit tests pinning every branch — including the regression: poisoned default-1 track_number does NOT block album backfill anymore.' },
|
||||
{ title: 'Wishlist: fix imports landing as track 01 with no year in folder name', desc: 'follow-up to the earlier wishlist album-bundle work. tracks with rich Spotify metadata were still importing as `01 - <title>` with no year in the folder path because three regressions stacked: the Track→dict conversion in payload helpers dropped everything except `album.name` (silently throwing away release_date / images / album_type / total_tracks), Deezer-sourced discovery matches saved their payloads without `track_number` / `disc_number` keys at all (Deezer uses `track_position` while the cache lookup read `track_number` literally), and the import pipeline only consulted `album_info.track_number` before falling to the filename — which fails for VA-collection source files like `417 Fountains of Wayne - Stacys Mom.flac` where the leading number is a playlist position not the album track. all three patched, with the track_number resolution chain lifted into `core/imports/track_number.py` as a pure function with 18 unit tests pinning every branch.' },
|
||||
|
|
|
|||
|
|
@ -62248,54 +62248,347 @@ body.reduce-effects .dash-card::after {
|
|||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* Enrichment / rate monitor — single row of compact tiles */
|
||||
.dash-card[data-card="enrichment"] .rate-monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 4px 0 0;
|
||||
/* Enrichment / rate monitor — VU-meter equalizer bars.
|
||||
*
|
||||
* Each service is a vertical bar capsule arranged in a flex row,
|
||||
* so the row stays symmetric at any service count (no orphan grid
|
||||
* cells when the service list isn't divisible by the column count).
|
||||
* Bar fill height = current rate / limit, with a 4% floor so idle
|
||||
* bars still show a sliver of accent.
|
||||
*
|
||||
* Variables on the host element:
|
||||
* --eq-accent service brand color (set per-bar from JS)
|
||||
* --eq-track-h vertical track height (responsive override below)
|
||||
*/
|
||||
.dash-card[data-card="enrichment"] .rate-monitor-grid--equalizer {
|
||||
--eq-track-h: 168px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 14px 4px 6px;
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .rate-gauge-card {
|
||||
padding: 10px 8px 8px;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
.rate-eq {
|
||||
--eq-accent: #888;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .rate-gauge-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
.rate-eq:focus-visible {
|
||||
outline: 2px solid var(--eq-accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .gauge-card-header {
|
||||
.rate-eq:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Track: glassy vertical capsule with subtle vignette */
|
||||
.rate-eq-track {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: var(--eq-track-h);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
rgba(255, 255, 255, 0.02) 0%,
|
||||
rgba(255, 255, 255, 0.04) 40%,
|
||||
rgba(0, 0, 0, 0.25) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
inset 0 -10px 24px rgba(0, 0, 0, 0.35);
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.rate-eq:hover .rate-eq-track {
|
||||
border-color: color-mix(in srgb, var(--eq-accent) 45%, rgba(255, 255, 255, 0.12));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
inset 0 -10px 24px rgba(0, 0, 0, 0.35),
|
||||
0 0 24px color-mix(in srgb, var(--eq-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Horizontal tick marks — like a real VU meter */
|
||||
.rate-eq-ticks {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
transparent 0 11px,
|
||||
rgba(255, 255, 255, 0.04) 11px 12px
|
||||
);
|
||||
mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 100%);
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 100%);
|
||||
}
|
||||
|
||||
/* The actual fill — accent gradient growing from the bottom */
|
||||
.rate-eq-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 4%;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--eq-accent) 90%, white 10%) 0%,
|
||||
var(--eq-accent) 35%,
|
||||
color-mix(in srgb, var(--eq-accent) 65%, transparent) 100%);
|
||||
border-radius: 0 0 11px 11px;
|
||||
transition: height 0.55s cubic-bezier(0.34, 1.2, 0.5, 1);
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--eq-accent) 65%, white 35%),
|
||||
0 0 20px color-mix(in srgb, var(--eq-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Bright glow strip at the top of the fill — the "VU peak" line */
|
||||
.rate-eq-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
background: color-mix(in srgb, var(--eq-accent) 30%, white 70%);
|
||||
box-shadow:
|
||||
0 0 6px color-mix(in srgb, var(--eq-accent) 80%, white),
|
||||
0 0 14px color-mix(in srgb, var(--eq-accent) 60%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Vertical shimmer scan — runs continuously when active */
|
||||
.rate-eq-shimmer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, white 22%, transparent) 50%,
|
||||
transparent 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.rate-eq.active .rate-eq-shimmer {
|
||||
opacity: 1;
|
||||
animation: rate-eq-shimmer-scan 2.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rate-eq-shimmer-scan {
|
||||
0% { transform: translateY(120%); opacity: 0; }
|
||||
18% { opacity: 1; }
|
||||
82% { opacity: 1; }
|
||||
100% { transform: translateY(-120%); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Active state — fill pulses subtly so the "alive" services radiate */
|
||||
.rate-eq.active .rate-eq-fill {
|
||||
animation: rate-eq-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rate-eq-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--eq-accent) 65%, white 35%),
|
||||
0 0 18px color-mix(in srgb, var(--eq-accent) 30%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--eq-accent) 65%, white 35%),
|
||||
0 0 32px color-mix(in srgb, var(--eq-accent) 55%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Idle floor: keep the 4% sliver visibly alive — a slow breathe so
|
||||
the row reads as standby, not dead. */
|
||||
.rate-eq:not(.active) .rate-eq-fill {
|
||||
animation: rate-eq-idle 4.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rate-eq-idle {
|
||||
0%, 100% { opacity: 0.65; }
|
||||
50% { opacity: 0.95; }
|
||||
}
|
||||
|
||||
/* Rate-limited state overrides accent with red + faster pulse */
|
||||
.rate-eq.rate-limited .rate-eq-fill,
|
||||
.rate-eq.danger .rate-eq-fill {
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
#ff8a8a 0%,
|
||||
#ef4444 35%,
|
||||
rgba(239, 68, 68, 0.5) 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 200, 200, 0.7),
|
||||
0 0 24px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.rate-eq.rate-limited .rate-eq-tip,
|
||||
.rate-eq.danger .rate-eq-tip {
|
||||
background: #ffd6d6;
|
||||
box-shadow: 0 0 8px #ef4444, 0 0 18px rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
.rate-eq.rate-limited .rate-eq-fill {
|
||||
animation: rate-eq-warn 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rate-eq-warn {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
50% { filter: brightness(1.4); }
|
||||
}
|
||||
|
||||
/* Big floating count number — overlay on the track, top-center */
|
||||
.rate-eq-value {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rate-eq.active .rate-eq-value {
|
||||
color: color-mix(in srgb, var(--eq-accent) 25%, white 75%);
|
||||
text-shadow:
|
||||
0 0 10px color-mix(in srgb, var(--eq-accent) 50%, transparent),
|
||||
0 2px 8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Meta: state pill + service name under the bar */
|
||||
.rate-eq-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .gauge-card-name {
|
||||
.rate-eq-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.rate-eq-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .gauge-card-status {
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
line-height: 1.3;
|
||||
.rate-eq-state-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .gauge-card-stats {
|
||||
display: none;
|
||||
.rate-eq-state[data-status="running"] {
|
||||
color: #6ee7a7;
|
||||
border-color: rgba(110, 231, 167, 0.25);
|
||||
background: rgba(110, 231, 167, 0.08);
|
||||
}
|
||||
.rate-eq-state[data-status="running"] .rate-eq-state-dot {
|
||||
background: #6ee7a7;
|
||||
box-shadow: 0 0 6px #6ee7a7;
|
||||
animation: rate-eq-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dash-card[data-card="enrichment"] .gauge-budget-bar {
|
||||
display: none;
|
||||
.rate-eq-state[data-status="paused"] {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.25);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
|
||||
.rate-eq-state[data-status="idle"] {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.rate-eq-state[data-status="not_configured"] {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.rate-eq.rate-limited .rate-eq-state {
|
||||
color: #fca5a5;
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
@keyframes rate-eq-dot-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px currentColor; }
|
||||
50% { box-shadow: 0 0 10px currentColor, 0 0 18px currentColor; }
|
||||
}
|
||||
|
||||
/* Responsive — tighten the bars on narrower viewports */
|
||||
@media (max-width: 1499px) {
|
||||
.dash-card[data-card="enrichment"] .rate-monitor-grid--equalizer {
|
||||
--eq-track-h: 156px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1099px) {
|
||||
.dash-card[data-card="enrichment"] .rate-monitor-grid--equalizer {
|
||||
--eq-track-h: 140px;
|
||||
gap: 4px;
|
||||
}
|
||||
.rate-eq-value { font-size: 16px; }
|
||||
.rate-eq-name { font-size: 10px; }
|
||||
.rate-eq-state { font-size: 8px; padding: 1px 6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dash-card[data-card="enrichment"] .rate-monitor-grid--equalizer {
|
||||
--eq-track-h: 124px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.rate-eq { flex: 0 0 calc(20% - 4px); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.rate-eq { flex: 0 0 calc(25% - 4px); }
|
||||
.dash-card[data-card="enrichment"] .rate-monitor-grid--equalizer {
|
||||
--eq-track-h: 108px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Active downloads container */
|
||||
|
|
|
|||
Loading…
Reference in a new issue