fix: composite layer opacity/brightness widgets + CSS layout
Lint & Test / test (push) Successful in 1m7s

- Fix opacity widget empty space (CSS selector .composite-layer-opacity → .composite-layer-opacity-container)
- Replace brightness select dropdown with BindableScalarWidget (slider + VS toggle)
- Legacy brightness_source_id auto-converted to BindableFloat on load
- Add .composite-layer-brightness-container CSS rule
This commit is contained in:
2026-03-29 00:42:42 +03:00
parent 8a17bb5caa
commit 78ce6c84d7
6 changed files with 151 additions and 44 deletions
@@ -268,7 +268,8 @@ def get_system_performance(_: AuthRequired):
# App-level metrics
proc_mem = _process.memory_info()
app_cpu = _process.cpu_percent(interval=None)
# Process.cpu_percent() is per-core (0N*100%); normalize to 0100% scale
app_cpu = _process.cpu_percent(interval=None) / (psutil.cpu_count(logical=True) or 1)
app_ram_mb = round(proc_mem.rss / 1024 / 1024, 1)
gpu = None
@@ -38,7 +38,8 @@ def _collect_system_snapshot() -> dict:
"ram_pct": mem.percent,
"ram_used": round(mem.used / 1024 / 1024, 1),
"ram_total": round(mem.total / 1024 / 1024, 1),
"app_cpu": _process.cpu_percent(interval=None),
# Process.cpu_percent() is per-core (0N*100%); normalize to 0100%
"app_cpu": _process.cpu_percent(interval=None) / (psutil.cpu_count(logical=True) or 1),
"app_ram": round(proc_mem.rss / 1024 / 1024, 1),
"gpu_util": None,
"gpu_temp": None,
@@ -368,12 +368,37 @@
}
}
/* ── Per-metric accent colors ── */
.perf-chart-card {
--perf-accent: var(--primary-color);
--perf-accent-glow: transparent;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-top: 3px solid var(--perf-accent);
border-radius: 6px;
padding: 10px 0 0;
min-width: 0;
position: relative;
transition: box-shadow var(--duration-normal) ease;
}
.perf-chart-card:hover {
box-shadow: 0 0 16px var(--perf-accent-glow);
}
.perf-chart-card[data-metric="cpu"] {
--perf-accent: #FF6B6B;
--perf-accent-glow: rgba(255, 107, 107, 0.12);
}
.perf-chart-card[data-metric="ram"] {
--perf-accent: #A855F7;
--perf-accent-glow: rgba(168, 85, 247, 0.12);
}
.perf-chart-card[data-metric="gpu"] {
--perf-accent: #10B981;
--perf-accent-glow: rgba(16, 185, 129, 0.12);
}
.perf-chart-wrap {
@@ -382,6 +407,16 @@
overflow: hidden;
}
/* Subtle gradient wash in chart background */
.perf-chart-wrap::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, var(--perf-accent-glow), transparent 60%);
pointer-events: none;
z-index: 0;
}
.perf-chart-header {
display: flex;
justify-content: space-between;
@@ -395,7 +430,21 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--text-secondary);
color: var(--perf-accent);
display: flex;
align-items: center;
gap: 6px;
}
/* Accent dot before label */
.perf-chart-label::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--perf-accent);
box-shadow: 0 0 6px var(--perf-accent);
flex-shrink: 0;
}
.perf-chart-subtitle {
@@ -414,10 +463,26 @@
z-index: 1;
}
/* ── Value display ── */
.perf-chart-value {
font-size: 0.85rem;
font-weight: 700;
color: var(--primary-text-color);
color: var(--perf-accent);
font-family: var(--font-mono, monospace);
display: flex;
align-items: baseline;
gap: 6px;
}
/* App value shown as subdued tag in "both" mode */
.perf-chart-value .perf-val-app {
font-size: 0.65rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.2px;
}
.perf-chart-label .color-picker-swatch {
@@ -433,6 +498,7 @@
font-size: 0.8rem;
}
/* ── Mode toggle ── */
.perf-mode-toggle {
display: inline-flex;
gap: 0;
@@ -1980,7 +1980,8 @@
flex-shrink: 0;
}
.composite-layer-opacity {
.composite-layer-opacity-container,
.composite-layer-brightness-container {
flex: 1;
min-width: 60px;
}
@@ -23,6 +23,7 @@ let _compositeLayers: any[] = [];
let _compositeAvailableSources: any[] = []; // non-composite sources for layer dropdowns
let _compositeSourceEntitySelects: any[] = [];
let _compositeBrightnessEntitySelects: any[] = [];
let _compositeBrightnessWidgets: BindableScalarWidget[] = [];
let _compositeBlendIconSelects: any[] = [];
let _compositeCSPTEntitySelects: any[] = [];
let _compositeOpacityWidgets: BindableScalarWidget[] = [];
@@ -51,6 +52,8 @@ export function compositeDestroyEntitySelects() {
_compositeCSPTEntitySelects = [];
_compositeOpacityWidgets.forEach(w => w.destroy());
_compositeOpacityWidgets = [];
_compositeBrightnessWidgets.forEach(w => w.destroy());
_compositeBrightnessWidgets = [];
}
function _getCompositeBlendItems() {
@@ -96,15 +99,10 @@ export function compositeRenderList() {
const list = document.getElementById('composite-layers-list') as HTMLElement | null;
if (!list) return;
compositeDestroyEntitySelects();
const vsList = _cachedValueSources || [];
list.innerHTML = _compositeLayers.map((layer, i) => {
const srcOptions = _compositeAvailableSources.map(s =>
`<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
const vsOptions = `<option value="">${t('color_strip.composite.brightness.none')}</option>` +
vsList.map(v =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const csptList = _cachedCSPTemplates || [];
const csptOptions = `<option value="">${t('common.none_no_cspt')}</option>` +
csptList.map(tmpl =>
@@ -151,7 +149,7 @@ export function compositeRenderList() {
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.brightness')}:</span>
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
<div class="composite-layer-brightness-container" data-idx="${i}"></div>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
@@ -247,15 +245,22 @@ export function compositeRenderList() {
}));
});
// Attach EntitySelect to each layer's brightness dropdown
list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness').forEach(sel => {
_compositeBrightnessEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeBrightnessItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('color_strip.composite.brightness.none'),
}));
// Create BindableScalarWidget for each layer's brightness
list.querySelectorAll<HTMLElement>('.composite-layer-brightness-container').forEach((container, i) => {
const layer = _compositeLayers[i];
// Convert legacy brightness_source_id to BindableFloat
const bfValue = layer?.brightness_source_id
? { value: 1.0, source_id: layer.brightness_source_id }
: 1.0;
const widget = new BindableScalarWidget({
container,
min: 0, max: 1, step: 0.05, default: 1.0,
idPrefix: `composite-brightness-${i}`,
format: (v) => v.toFixed(2),
valueSources: () => _cachedValueSources,
});
widget.setValue(bfValue);
_compositeBrightnessWidgets.push(widget);
});
// Attach EntitySelect to each layer's CSPT dropdown
@@ -314,7 +319,6 @@ function _compositeLayersSyncFromDom() {
const srcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-source');
const blends = list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend');
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
const starts = list.querySelectorAll<HTMLInputElement>('.composite-layer-start');
const ends = list.querySelectorAll<HTMLInputElement>('.composite-layer-end');
@@ -325,7 +329,13 @@ function _compositeLayersSyncFromDom() {
_compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = _compositeOpacityWidgets[i]?.getValue() ?? _compositeLayers[i].opacity;
_compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
// Brightness: read from widget (BindableFloat → extract source_id for legacy field)
const briVal = _compositeBrightnessWidgets[i]?.getValue();
if (briVal && typeof briVal === 'object' && 'source_id' in briVal) {
_compositeLayers[i].brightness_source_id = briVal.source_id || null;
} else {
_compositeLayers[i].brightness_source_id = null;
}
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
_compositeLayers[i].start = starts[i] ? (parseInt(starts[i].value) || 0) : 0;
_compositeLayers[i].end = ends[i] ? (parseInt(ends[i].value) || 0) : 0;
@@ -17,6 +17,20 @@ const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
const PERF_MODE_KEY = 'perfMetricsMode';
/** Default accent colors per metric — distinct hues for visual identity. */
const METRIC_COLORS: Record<string, string> = {
cpu: '#FF6B6B', // warm coral
ram: '#A855F7', // electric violet
gpu: '#10B981', // emerald teal
};
/** Complementary app/process line colors — clearly different hue per metric. */
const APP_COLORS: Record<string, string> = {
cpu: '#FFB347', // amber
ram: '#60A5FA', // sky blue
gpu: '#34D399', // mint
};
type PerfMode = 'system' | 'app' | 'both';
let _pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -28,14 +42,12 @@ let _mode: PerfMode = (localStorage.getItem(PERF_MODE_KEY) as PerfMode) || 'both
function _getColor(key: string): string {
return localStorage.getItem(`perfChartColor_${key}`)
|| getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|| METRIC_COLORS[key]
|| '#4CAF50';
}
function _getAppColor(key: string): string {
const base = _getColor(key);
// Use a lighter/shifted version for the app line
return base + '99'; // 60% opacity hex suffix
return APP_COLORS[key] || _getColor(key) + '99';
}
function _onChartColorChange(key: string, hex: string | null): void {
@@ -53,10 +65,15 @@ function _onChartColorChange(key: string, hex: string | null): void {
if (chart) {
chart.data.datasets[0].borderColor = hex;
chart.data.datasets[0].backgroundColor = hex + '26';
chart.data.datasets[1].borderColor = hex + '99';
chart.data.datasets[1].backgroundColor = hex + '14';
// App line keeps its distinct hue (only update if user explicitly reset)
const appColor = _getAppColor(key);
chart.data.datasets[1].borderColor = appColor;
chart.data.datasets[1].backgroundColor = appColor + '1A';
chart.update();
}
// Update card accent color
const card = document.querySelector(`.perf-chart-card[data-metric="${key}"]`) as HTMLElement | null;
if (card) card.style.setProperty('--perf-accent', hex!);
}
/** Build the 3-way toggle HTML for perf section header. */
@@ -98,21 +115,21 @@ export function renderPerfSection(): string {
}
return `<div class="perf-charts-grid">
<div class="perf-chart-card">
<div class="perf-chart-card" data-metric="cpu">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-cpu-value">-</span>
</div>
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><canvas id="perf-chart-cpu"></canvas></div>
</div>
<div class="perf-chart-card">
<div class="perf-chart-card" data-metric="ram">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-ram-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div>
<div class="perf-chart-card" id="perf-gpu-card">
<div class="perf-chart-card" data-metric="gpu" id="perf-gpu-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), onPick: undefined, anchor: 'left', showReset: true })}</span>
<span class="perf-chart-value" id="perf-gpu-value">-</span>
@@ -126,6 +143,7 @@ function _createChart(canvasId: string, key: string): any {
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
if (!ctx) return null;
const color = _getColor(key);
const appColor = _getAppColor(key);
const showSystem = _mode === 'system' || _mode === 'both';
const showApp = _mode === 'app' || _mode === 'both';
return new Chart(ctx, {
@@ -134,21 +152,21 @@ function _createChart(canvasId: string, key: string): any {
labels: Array(MAX_SAMPLES).fill(''),
datasets: [
{
// System-wide dataset
// System-wide dataset — bold solid line
data: [],
borderColor: color,
backgroundColor: color + '26',
borderWidth: 1.5,
borderWidth: 2,
tension: 0.3,
fill: true,
pointRadius: 0,
hidden: !showSystem,
},
{
// App-level dataset (dashed line)
// App-level dataset — distinct hue, dashed
data: [],
borderColor: color + '99',
backgroundColor: color + '14',
borderColor: appColor,
backgroundColor: appColor + '1A',
borderWidth: 1.5,
borderDash: [4, 3],
tension: 0.3,
@@ -257,15 +275,25 @@ function _pushSample(key: string, sysValue: number, appValue: number | null): vo
chart.update('none');
}
/** Format the value display based on mode. */
/** Format the value display based on mode.
* In "both" mode, returns HTML with a styled app-value tag. */
function _formatValue(sysVal: string, appVal: string | null): string {
if (_mode === 'system') return sysVal;
if (_mode === 'app') return appVal ?? '-';
// 'both': show both
if (appVal != null) return `${sysVal} / ${appVal}`;
// 'both': system value prominent, app value as subdued badge
if (appVal != null) return `${sysVal} <span class="perf-val-app">${appVal}</span>`;
return sysVal;
}
/** Apply formatted value — uses innerHTML when in "both" mode for styled tags. */
function _setValueEl(el: HTMLElement, html: string): void {
if (_mode === 'both') {
el.innerHTML = html;
} else {
el.textContent = html;
}
}
async function _fetchPerformance(): Promise<void> {
try {
const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() });
@@ -276,10 +304,10 @@ async function _fetchPerformance(): Promise<void> {
_pushSample('cpu', data.cpu_percent, data.app_cpu_percent);
const cpuEl = document.getElementById('perf-cpu-value');
if (cpuEl) {
cpuEl.textContent = _formatValue(
_setValueEl(cpuEl, _formatValue(
`${data.cpu_percent.toFixed(0)}%`,
`${data.app_cpu_percent.toFixed(0)}%`
);
));
}
if (data.cpu_name) {
const nameEl = document.getElementById('perf-cpu-name');
@@ -296,10 +324,10 @@ async function _fetchPerformance(): Promise<void> {
const usedGb = (data.ram_used_mb / 1024).toFixed(1);
const totalGb = (data.ram_total_mb / 1024).toFixed(1);
const appMb = data.app_ram_mb.toFixed(0);
ramEl.textContent = _formatValue(
_setValueEl(ramEl, _formatValue(
`${usedGb}/${totalGb} GB`,
`${appMb} MB`
);
));
}
// GPU
@@ -317,7 +345,7 @@ async function _fetchPerformance(): Promise<void> {
const appText = data.gpu.app_memory_mb != null
? `${data.gpu.app_memory_mb.toFixed(0)} MB VRAM`
: null;
gpuEl.textContent = _formatValue(sysText, appText);
_setValueEl(gpuEl, _formatValue(sysText, appText));
}
if (data.gpu.name) {
const nameEl = document.getElementById('perf-gpu-name');