feat(ui): item-card restyle, perf hover tooltips, FPS ceiling
Item cards (Automations, Channels, Inputs, Integrations):
- `.card-title` — bumped to weight 700, -0.01em tracking, solid --lux-ink
for better presence against the flat card bg.
- `.card-subtitle` / `.card-meta` — mono font, 0.04em tracking, tighter
gap so rule chips pack in a readable row.
- `.stream-card-prop` rule chips — rectangular 2px radius + hairline
border + flat dark bg (was rounded 10px grey pill). Channel-signal
icon tint; hover fades in a channel-green wash with matching border.
- `.badge` generic — rectangular 2px radius, mono 0.62rem, 0.12em
tracking, hairline border slot for variants.
- `.badge-automation-active` — channel-signal tinted bg + border +
soft outer glow so the "ACTIVE" state reads at a glance.
- `.badge-automation-inactive` / `-disabled` — transparent with a
hairline outline so they sit quietly alongside the active variant.
- `.device-url-badge` — switched from rounded pill to rectangular
hairline mono chip; hover shifts to filled bg + bolder border +
brighter ink.
- `.card-actions` — 1px hairline top divider, 6px gap.
- `.btn-icon` — 7/10px padding, 1rem icon, hairline border, channel-
signal glow on hover (replaces the old scale(1.1) jiggle).
- `.btn-icon.btn-warning` — amber ink + hairline + amber hover glow
(drives the "disable" action in the automation card).
- `.btn-icon.btn-success` — signal-green ink + hairline + green hover
glow ("enable" action).
Cross-link navigation highlight:
- `cardHighlight` keyframes were using an undefined `--primary-rgb` var,
so the outer glow fell back to 59/130/246 (the Tailwind blue default).
Rewritten with `var(--ch-signal)` + color-mix so the highlight tracks
the accent picker and reads as signal-green. Added double-layer
box-shadow (ring + 32px/10px bloom) so the highlight is obvious on
the flat dark/light card surfaces. Added .dashboard-target to the
selector + `isolation: isolate` so the glow isn't clipped inside
overflow: hidden containers (perf strip cells, tree-nav panels).
Perf strip (follow-up polish):
- Total FPS cell shows `/<N>` ceiling suffix next to the live value —
sum of fps_target across running targets, styled like the Patches
"/12". A dashed horizontal reference line at that ceiling is rendered
on the sparkline so the live value reads as "percentage of max
achievable throughput." Y-axis ceiling grows to targetSum * 1.1 so
the dashed line never clips.
- Removed the empty `.perf-chart-app` pill in the FPS cell (no app
variant). Added `:empty { display: none }` as a safety so any other
unpopulated cell doesn't render a ghost pill.
- Hover tooltips on all sparks — single floating `.perf-chart-tooltip`
in <body> with fixed positioning; event-delegated from the perf
grid so re-renders don't need rebinding. Shows metric label + sys
value + app value (in both-mode) + "−Ns ago" age line derived from
the poll interval. Vertical marker line follows the cursor over the
spark; `cursor: crosshair` on the spark container signals interact-
ability. `pointer-events: none` shifted from the spark container
down to the inner SVG so hover events land on the container.
Grid:
- Perf strip capped at 4 cols even on widescreen; wraps to 2 rows ×
4 when the full 7 cells are present. Responsive breakpoints at
1100 / 760 / 480 px.
- Big value font uses `clamp(1.8rem, 2.8vw, 2.8rem)` so readouts
like "18.9/31.8 GB" fit a 1fr cell at desktop while still scaling
down on narrow viewports. `white-space: nowrap; flex-wrap: nowrap;
overflow: hidden; text-overflow: clip` prevents mid-text wrapping.
- `.perf-chart-spark` uses `margin-top: auto` so sparkline baselines
align across cells regardless of whether a subtitle is present
(CPU/GPU model name, FPS min/max).
Dashboard target meta:
- Integrations card stripe reverted to the default signal color so it
matches the overall accent picker; the health-dot inside the card
carries the connection state. Removed the per-integration channel
override in both cards.css and dashboard.css.
Section headers:
- `.dashboard-section-header` / `.subtab-section-header` underline
switched from dashed to solid; channel-green 40px accent rule on
the left remains.
- Section count badge (`.dashboard-section-count`) restyled to match
the rest of the badge family (mono tabular-nums, 2px radius, hairline
border, --lux-bg-3 fill).
Build: tsc --noEmit clean; CSS bundle stable at ~216 KB.
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
/* ===== AUTOMATIONS ===== */
|
||||
|
||||
.badge-automation-active {
|
||||
background: var(--success-color);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 45%, transparent);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 20%, transparent);
|
||||
}
|
||||
|
||||
.badge-automation-inactive {
|
||||
background: var(--border-color);
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
border-color: var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
}
|
||||
|
||||
.badge-automation-disabled {
|
||||
background: var(--border-color);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
background: transparent;
|
||||
border-color: var(--lux-line, var(--border-color));
|
||||
color: var(--lux-ink-mute, var(--text-muted));
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.automation-status-disabled {
|
||||
|
||||
@@ -582,18 +582,21 @@ body.cs-drag-active .card-drag-handle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding-right: 60px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-body, inherit);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.card-title-text {
|
||||
@@ -613,17 +616,18 @@ body.cs-drag-active .card-drag-handle {
|
||||
.device-url-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
background: var(--border-color);
|
||||
gap: 5px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
background: var(--lux-bg-0, var(--border-color));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
font-family: monospace;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
@@ -636,7 +640,9 @@ body.cs-drag-active .card-drag-handle {
|
||||
}
|
||||
|
||||
.device-url-badge:hover {
|
||||
background: var(--text-muted);
|
||||
background: var(--lux-bg-2, var(--text-muted));
|
||||
border-color: var(--lux-line-bold, var(--border-color));
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
}
|
||||
|
||||
.device-url-icon {
|
||||
@@ -650,17 +656,19 @@ body.cs-drag-active .card-drag-handle {
|
||||
.card-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 15px;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.card-meta .icon {
|
||||
|
||||
@@ -23,16 +23,17 @@
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-top: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-actions .btn-icon {
|
||||
padding: 6px 8px;
|
||||
font-size: 1.1rem;
|
||||
padding: 7px 10px;
|
||||
min-width: 36px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -95,14 +96,51 @@
|
||||
|
||||
.btn-icon {
|
||||
min-width: auto;
|
||||
padding: 8px 12px;
|
||||
font-size: 1.2rem;
|
||||
padding: 7px 10px;
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
background: transparent;
|
||||
color: var(--lux-ink-dim, var(--text-color));
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
transform: scale(1.1);
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
background: var(--lux-bg-2, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 35%, var(--lux-line-bold, var(--border-color)));
|
||||
filter: none;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Variant: warning / success for enable/disable action buttons. Keep
|
||||
flat hairline borders; just shift the color + hover glow. */
|
||||
.btn-icon.btn-warning {
|
||||
color: var(--ch-amber, var(--warning-color));
|
||||
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 35%, transparent);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-icon.btn-warning:hover {
|
||||
background: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 12%, transparent);
|
||||
color: var(--ch-amber, var(--warning-color));
|
||||
border-color: color-mix(in srgb, var(--ch-amber, var(--warning-color)) 50%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--ch-amber, var(--warning-color)) 25%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon.btn-success {
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, transparent);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-icon.btn-success:hover {
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 14%, transparent);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 30%, transparent);
|
||||
}
|
||||
|
||||
.btn-icon:active:not(:disabled) {
|
||||
|
||||
@@ -961,6 +961,91 @@
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Hide the pill when there's nothing to show (host-only metrics, or a
|
||||
mode/state that has no app variant). Avoids a ghost bordered box in
|
||||
the top-right corner. */
|
||||
.perf-chart-app:empty { display: none; }
|
||||
|
||||
/* ── Spark hover tooltip (single floating element, reused across all
|
||||
cards; positioned via JS; inline layout reads like an instrument
|
||||
readout). ── */
|
||||
.perf-chart-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
z-index: var(--z-toast, 3500);
|
||||
background: var(--lux-bg-1, var(--card-bg));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line-bold, var(--border-color));
|
||||
border-radius: var(--lux-r-sm, 3px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.02),
|
||||
0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 10px 6px;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.3;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.perf-chart-tooltip .perf-tip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.perf-chart-tooltip .perf-tip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
.perf-chart-tooltip .perf-tip-dot-app {
|
||||
background: transparent;
|
||||
border: var(--lux-hairline, 1px) solid currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
.perf-chart-tooltip .perf-tip-k {
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-right: auto;
|
||||
}
|
||||
.perf-chart-tooltip .perf-tip-v {
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.perf-chart-tooltip .perf-tip-app .perf-tip-v {
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
font-weight: 500;
|
||||
}
|
||||
.perf-chart-tooltip .perf-tip-age {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: var(--lux-hairline, 1px) dashed var(--lux-line, var(--border-color));
|
||||
font-size: 0.6rem;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Vertical marker line over the spark at cursor x. */
|
||||
.perf-chart-tooltip-marker {
|
||||
position: fixed;
|
||||
display: none;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
background: var(--marker-color, var(--ch-signal, var(--primary-color)));
|
||||
opacity: 0.55;
|
||||
z-index: calc(var(--z-toast, 3500) - 1);
|
||||
box-shadow: 0 0 6px var(--marker-color, var(--ch-signal, var(--primary-color)));
|
||||
}
|
||||
|
||||
/* Hide the idle corner bracket on perf cards — the APP tag now
|
||||
owns that slot in 'both' mode. */
|
||||
.perf-chart-card::after {
|
||||
@@ -1015,16 +1100,22 @@
|
||||
bottom. `margin-top: auto` pushes it to the bottom so the spark
|
||||
baseline aligns across cells regardless of subtitle presence —
|
||||
cells with CPU/GPU model names, FPS min/max etc. no longer have a
|
||||
higher spark than cells without a subtitle. */
|
||||
higher spark than cells without a subtitle.
|
||||
Pointer events stay enabled so hover tooltips work; the SVG itself
|
||||
is non-interactive via `perf-chart-svg` below. */
|
||||
.perf-chart-spark {
|
||||
position: relative;
|
||||
margin-top: auto;
|
||||
height: 42px;
|
||||
padding: 0 18px 14px;
|
||||
pointer-events: none;
|
||||
cursor: crosshair;
|
||||
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--perf-accent) 45%, transparent));
|
||||
}
|
||||
|
||||
.perf-chart-spark .perf-chart-svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.perf-chart-spark .perf-chart-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1214,6 +1305,19 @@
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Target-FPS ceiling suffix — "/ 120" next to the big live number, sized
|
||||
down + muted so the live value remains the primary reading. Matches
|
||||
the "/ 12" style from the Active Patches cell. */
|
||||
.perf-chart-card[data-metric="fps"] .perf-fps-ceiling {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.38em;
|
||||
font-weight: 500;
|
||||
color: var(--lux-ink-mute, var(--text-secondary));
|
||||
letter-spacing: 0.04em;
|
||||
margin-left: 4px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Hint mode — the card is revealed with an explanatory message instead
|
||||
of a live metric (e.g. Windows without LibreHardwareMonitor for CPU
|
||||
temp). Neutralizes the big display font + hides the sparkline so the
|
||||
|
||||
@@ -42,21 +42,30 @@
|
||||
}
|
||||
|
||||
.stream-card-prop {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--border-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
color: var(--lux-ink-dim, var(--text-secondary));
|
||||
background: var(--lux-bg-0, var(--border-color));
|
||||
border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color));
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
max-width: 220px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stream-card-prop .icon {
|
||||
color: var(--primary-text-color);
|
||||
color: var(--ch-signal, var(--primary-color));
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stream-card-prop-full {
|
||||
@@ -65,18 +74,19 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.stream-card-link {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stream-card-link:hover {
|
||||
background: var(--primary-color);
|
||||
color: var(--primary-contrast);
|
||||
background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 15%, transparent);
|
||||
color: var(--lux-ink, var(--text-color));
|
||||
border-color: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 40%, var(--lux-line, var(--border-color)));
|
||||
}
|
||||
|
||||
.stream-card-link:hover .icon {
|
||||
@@ -84,15 +94,31 @@
|
||||
}
|
||||
|
||||
@keyframes cardHighlight {
|
||||
0%, 100% { box-shadow: none; }
|
||||
25%, 75% { box-shadow: 0 0 0 3px var(--primary-color), 0 0 20px rgba(var(--primary-rgb, 59, 130, 246), 0.3); }
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 0 0 color-mix(in srgb, var(--ch-signal, var(--primary-color)) 0%, transparent),
|
||||
0 0 0 0 transparent;
|
||||
}
|
||||
25%, 75% {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--ch-signal, var(--primary-color)),
|
||||
0 0 32px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 55%, transparent),
|
||||
0 0 10px color-mix(in srgb, var(--ch-signal, var(--primary-color)) 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.card-highlight,
|
||||
.template-card.card-highlight {
|
||||
animation: cardHighlight 2s ease-in-out;
|
||||
.template-card.card-highlight,
|
||||
.dashboard-target.card-highlight {
|
||||
animation: cardHighlight 2.2s ease-in-out;
|
||||
position: relative;
|
||||
z-index: 11;
|
||||
/* Nudge the card forward during the highlight so the outer glow
|
||||
isn't clipped by a containing overflow: hidden (strip cells,
|
||||
tree-nav panels). Box-shadow is never clipped by the element's
|
||||
own overflow but *is* clipped by parent overflow in stacking
|
||||
contexts where the card doesn't escape. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Dim overlay behind highlighted card */
|
||||
|
||||
@@ -93,13 +93,19 @@
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
border: var(--lux-hairline, 1px) solid transparent;
|
||||
font-family: var(--font-mono, inherit);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
|
||||
@@ -599,9 +599,11 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
enriched.length,
|
||||
);
|
||||
// Aggregate throughput across all running targets — fills the
|
||||
// Total FPS cell in the perf strip.
|
||||
// Total FPS cell in the perf strip. `fpsTargetSum` is drawn as
|
||||
// a dashed reference line ("max achievable throughput").
|
||||
const fpsValues: number[] = [];
|
||||
let fpsSum = 0;
|
||||
let fpsTargetSum = 0;
|
||||
for (const r of running) {
|
||||
const fps = r.state?.fps_actual != null ? r.state.fps_actual
|
||||
: r.state?.fps_current != null ? r.state.fps_current
|
||||
@@ -610,10 +612,14 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
fpsValues.push(fps);
|
||||
fpsSum += fps;
|
||||
}
|
||||
const tgt = r.state?.fps_target
|
||||
?? (r.settings || {}).fps
|
||||
?? r.update_rate;
|
||||
if (typeof tgt === 'number' && tgt > 0) fpsTargetSum += tgt;
|
||||
}
|
||||
const fpsMin = fpsValues.length > 0 ? Math.min(...fpsValues) : null;
|
||||
const fpsMax = fpsValues.length > 0 ? Math.max(...fpsValues) : null;
|
||||
updateTotalFps(fpsSum, fpsMin, fpsMax);
|
||||
updateTotalFps(fpsSum, fpsMin, fpsMax, fpsTargetSum);
|
||||
|
||||
// Check if we can do an in-place metrics update (same targets, not first load)
|
||||
const newRunningIds = running.map(t => t.id).sort().join(',');
|
||||
|
||||
@@ -51,6 +51,9 @@ let _appHistory: Record<string, number[]> = { cpu: [], ram: [], gpu: [], temp: [
|
||||
/** Peak FPS observed during the session — used as the y-axis ceiling for
|
||||
* the FPS sparkline so slow targets look proportional to fast ones. */
|
||||
let _fpsPeak = 60;
|
||||
/** Sum of fps_target across running targets — rendered as a dashed
|
||||
* reference line on the FPS spark ("max achievable throughput"). */
|
||||
let _fpsTargetSum = 0;
|
||||
let _hasGpu: boolean | null = null;
|
||||
let _hasTemp: boolean | null = null;
|
||||
let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both';
|
||||
@@ -156,7 +159,6 @@ export function renderPerfSection(): string {
|
||||
<div class="perf-chart-card" data-metric="fps" data-perf-mode="${_mode}">
|
||||
<div class="perf-chart-header">
|
||||
<span class="perf-chart-label">${t('dashboard.perf.total_fps') || 'Total FPS'}</span>
|
||||
<span class="perf-chart-app" id="perf-fps-app" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="perf-chart-body">
|
||||
<div class="perf-chart-value-block">
|
||||
@@ -234,16 +236,28 @@ function escapeText(s: string): string {
|
||||
|
||||
/** Total FPS cell — pushed a new sample each dashboard refresh cycle.
|
||||
* `totalFps` is the sum of fps_actual across running targets; `minFps`
|
||||
* / `maxFps` are the live extremes shown as a subdued subtitle. */
|
||||
export function updateTotalFps(totalFps: number, minFps: number | null, maxFps: number | null): void {
|
||||
* / `maxFps` are the live extremes shown as a subdued subtitle;
|
||||
* `targetSum` is the sum of each running target's fps_target and is
|
||||
* drawn as a dashed "max" reference line on the spark. */
|
||||
export function updateTotalFps(
|
||||
totalFps: number,
|
||||
minFps: number | null,
|
||||
maxFps: number | null,
|
||||
targetSum: number = 0,
|
||||
): void {
|
||||
const fps = Math.max(0, totalFps);
|
||||
_history.fps.push(fps);
|
||||
if (_history.fps.length > MAX_SAMPLES) _history.fps.shift();
|
||||
if (fps > _fpsPeak) _fpsPeak = fps;
|
||||
_fpsTargetSum = Math.max(0, targetSum || 0);
|
||||
|
||||
const valEl = document.getElementById('perf-fps-value');
|
||||
if (valEl) {
|
||||
valEl.innerHTML = `${fps.toFixed(fps < 10 ? 1 : 0)}<span class="perf-fps-unit">fps</span>`;
|
||||
const fpsText = fps.toFixed(fps < 10 ? 1 : 0);
|
||||
const ceilingSuffix = _fpsTargetSum > 0
|
||||
? `<span class="perf-fps-ceiling">/ ${Math.round(_fpsTargetSum)}</span>`
|
||||
: '';
|
||||
valEl.innerHTML = `${fpsText}${ceilingSuffix}<span class="perf-fps-unit">fps</span>`;
|
||||
}
|
||||
const subEl = document.getElementById('perf-fps-sub');
|
||||
if (subEl) {
|
||||
@@ -310,15 +324,25 @@ function _renderChartSvg(key: string): void {
|
||||
const showSystem = _mode === 'system' || _mode === 'both';
|
||||
const showApp = !isHostOnly && (_mode === 'app' || _mode === 'both');
|
||||
|
||||
// Scale y per metric — temp varies 20..90°C; fps uses a session peak
|
||||
// with a 60 floor so a 30 FPS signal fills ~half the cell; others
|
||||
// are 0..100 %.
|
||||
// Scale y per metric — temp varies 20..90°C; fps uses whichever is
|
||||
// larger of the session peak or the target-sum ceiling with some
|
||||
// headroom; others are 0..100 %.
|
||||
const yMin = key === 'temp' ? 20 : 0;
|
||||
const yMax = key === 'temp' ? 100
|
||||
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1)
|
||||
: key === 'fps' ? Math.max(60, _fpsPeak * 1.1, _fpsTargetSum * 1.1)
|
||||
: 100;
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
// FPS-only: dashed "target ceiling" reference line at the sum of
|
||||
// fps_target across running targets, so the spark reads as "live
|
||||
// throughput relative to max achievable."
|
||||
if (key === 'fps' && _fpsTargetSum > 0 && _fpsTargetSum <= yMax) {
|
||||
const span = yMax - yMin || 1;
|
||||
const refY = SPARK_H - ((_fpsTargetSum - yMin) / span) * (SPARK_H - 2) - 1;
|
||||
paths.push(`<line x1="0" y1="${refY.toFixed(1)}" x2="${SPARK_W}" y2="${refY.toFixed(1)}" stroke="${color}" stroke-width="1" stroke-dasharray="5 4" opacity="0.4" />`);
|
||||
}
|
||||
|
||||
if (showSystem && sys.length > 1) {
|
||||
paths.push(_pathFor(sys, yMin, yMax, color, 'sys'));
|
||||
}
|
||||
@@ -585,9 +609,129 @@ async function _seedFromServer(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialize perf section — paint from server-side history. */
|
||||
/** Initialize perf section — paint from server-side history and wire up
|
||||
* spark hover tooltips. */
|
||||
export async function initPerfCharts(): Promise<void> {
|
||||
await _seedFromServer();
|
||||
_initSparkTooltip();
|
||||
}
|
||||
|
||||
// ─── Spark hover tooltip ─────────────────────────────────────────
|
||||
|
||||
/** Single shared tooltip + marker element lazy-created on first hover. */
|
||||
let _tooltipEl: HTMLDivElement | null = null;
|
||||
let _tooltipMarkerEl: HTMLDivElement | null = null;
|
||||
|
||||
function _ensureTooltip(): HTMLDivElement {
|
||||
if (_tooltipEl) return _tooltipEl;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'perf-chart-tooltip';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(el);
|
||||
_tooltipEl = el;
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'perf-chart-tooltip-marker';
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(marker);
|
||||
_tooltipMarkerEl = marker;
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Format a sampled value per metric for the tooltip line. */
|
||||
function _formatSampleValue(key: string, v: number): string {
|
||||
if (key === 'temp') return `${v.toFixed(1)}°C`;
|
||||
if (key === 'fps') return `${v.toFixed(v < 10 ? 1 : 0)} FPS`;
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function _metricLabel(key: string): string {
|
||||
if (key === 'cpu') return 'CPU';
|
||||
if (key === 'ram') return 'RAM';
|
||||
if (key === 'gpu') return 'GPU';
|
||||
if (key === 'temp') return 'Temp';
|
||||
if (key === 'fps') return 'Total FPS';
|
||||
return key.toUpperCase();
|
||||
}
|
||||
|
||||
function _initSparkTooltip(): void {
|
||||
const intervalMs = dashboardPollInterval || 2000;
|
||||
// Event-delegate from .perf-charts-grid so re-renders of the perf
|
||||
// section don't require re-binding per spark.
|
||||
const grid = document.querySelector('.perf-charts-grid');
|
||||
if (!grid) return;
|
||||
|
||||
grid.addEventListener('mousemove', (rawEv) => {
|
||||
const ev = rawEv as MouseEvent;
|
||||
const target = ev.target as HTMLElement;
|
||||
const spark = target.closest('.perf-chart-spark') as HTMLElement | null;
|
||||
if (!spark) { _hideTooltip(); return; }
|
||||
const card = spark.closest('.perf-chart-card') as HTMLElement | null;
|
||||
if (!card) { _hideTooltip(); return; }
|
||||
const key = card.dataset.metric;
|
||||
if (!key || !_history[key]) { _hideTooltip(); return; }
|
||||
|
||||
const rect = spark.getBoundingClientRect();
|
||||
const sys = _history[key];
|
||||
const app = _appHistory[key];
|
||||
if (sys.length < 2) { _hideTooltip(); return; }
|
||||
|
||||
// Samples right-align in the spark (new tick arrives at the right
|
||||
// edge), so cursor x → index in the last-N window.
|
||||
const relX = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
|
||||
const fraction = rect.width > 0 ? relX / rect.width : 0;
|
||||
// The visible series maps to the rightmost sys.length samples in
|
||||
// a MAX_SAMPLES-wide viewBox — compute which actual sample the
|
||||
// cursor x corresponds to.
|
||||
const visibleStart = MAX_SAMPLES - sys.length;
|
||||
const globalIdx = Math.round(fraction * (MAX_SAMPLES - 1));
|
||||
const localIdx = Math.max(0, Math.min(sys.length - 1, globalIdx - visibleStart));
|
||||
const sysValue = sys[localIdx];
|
||||
const appValue = app && app.length > localIdx ? app[localIdx] : null;
|
||||
|
||||
// "-Ns ago" based on sample age (newest is rightmost).
|
||||
const ageSecs = Math.round((sys.length - 1 - localIdx) * (intervalMs / 1000));
|
||||
|
||||
const tip = _ensureTooltip();
|
||||
const color = _getColor(key);
|
||||
const sysLine = `<span class="perf-tip-dot" style="background:${color}"></span>
|
||||
<span class="perf-tip-k">${_metricLabel(key)}</span>
|
||||
<span class="perf-tip-v">${_formatSampleValue(key, sysValue)}</span>`;
|
||||
const appLine = (appValue != null)
|
||||
? `<div class="perf-tip-row perf-tip-app">
|
||||
<span class="perf-tip-dot perf-tip-dot-app" style="border-color:${color}"></span>
|
||||
<span class="perf-tip-k">App</span>
|
||||
<span class="perf-tip-v">${_formatSampleValue(key, appValue)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
const ageLine = `<div class="perf-tip-age">${ageSecs === 0 ? 'now' : `−${ageSecs}s`}</div>`;
|
||||
tip.innerHTML = `<div class="perf-tip-row">${sysLine}</div>${appLine}${ageLine}`;
|
||||
tip.style.display = 'block';
|
||||
|
||||
// Position tip above cursor, clamped to viewport.
|
||||
const tipRect = tip.getBoundingClientRect();
|
||||
let tipLeft = ev.clientX - tipRect.width / 2;
|
||||
let tipTop = rect.top - tipRect.height - 10;
|
||||
if (tipTop < 6) tipTop = rect.bottom + 10; // flip below if no room above
|
||||
tipLeft = Math.max(6, Math.min(window.innerWidth - tipRect.width - 6, tipLeft));
|
||||
tip.style.left = `${tipLeft}px`;
|
||||
tip.style.top = `${tipTop}px`;
|
||||
|
||||
// Vertical marker line over the spark at cursor x.
|
||||
if (_tooltipMarkerEl) {
|
||||
_tooltipMarkerEl.style.display = 'block';
|
||||
_tooltipMarkerEl.style.left = `${ev.clientX}px`;
|
||||
_tooltipMarkerEl.style.top = `${rect.top}px`;
|
||||
_tooltipMarkerEl.style.height = `${rect.height}px`;
|
||||
_tooltipMarkerEl.style.setProperty('--marker-color', color);
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('mouseleave', _hideTooltip);
|
||||
}
|
||||
|
||||
function _hideTooltip(): void {
|
||||
if (_tooltipEl) _tooltipEl.style.display = 'none';
|
||||
if (_tooltipMarkerEl) _tooltipMarkerEl.style.display = 'none';
|
||||
}
|
||||
|
||||
export function startPerfPolling(): void {
|
||||
|
||||
Reference in New Issue
Block a user