Add card color system with wrapCard helper and reset support

Introduce localStorage-backed card color assignment for all card types
with a reusable wrapCard() helper that provides consistent card shell
structure (top actions, bottom actions with color picker). Move color
picker from top-right to bottom-right action bar. Add color reset
button to clear card color back to default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 21:55:29 +03:00
parent fa81d6a608
commit 9b2ccde8a7
15 changed files with 329 additions and 125 deletions

View File

@@ -10,6 +10,7 @@ import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { csScenes, createSceneCard } from './scene-presets.js';
class AutomationEditorModal extends Modal {
@@ -152,11 +153,13 @@ function createAutomationCard(automation, sceneMap = new Map()) {
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
}
return `
<div class="card${!automation.enabled ? ' automation-status-disabled' : ''}" data-automation-id="${automation.id}">
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')" title="${t('common.delete')}">&#x2715;</button>
</div>
return wrapCard({
dataAttr: 'data-automation-id',
id: automation.id,
classes: !automation.enabled ? 'automation-status-disabled' : '',
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title">
${escapeHtml(automation.name)}
@@ -169,14 +172,13 @@ function createAutomationCard(automation, sceneMap = new Map()) {
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
${lastActivityMeta}
</div>
<div class="stream-card-props">${condPills}</div>
<div class="card-actions">
<div class="stream-card-props">${condPills}</div>`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_PAUSE : ICON_START}
</button>
</div>
</div>`;
</button>`,
});
}
export async function openAutomationEditor(automationId) {

View File

@@ -13,6 +13,7 @@ import {
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
class CSSEditorModal extends Modal {
constructor() {
@@ -707,9 +708,12 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
return `
<div class="card" data-css-id="${source.id}">
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
dataAttr: 'data-css-id',
id: source.id,
removeOnclick: `deleteColorStrip('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title">
${icon} ${escapeHtml(source.name)}
@@ -717,14 +721,12 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
</div>
<div class="stream-card-props">
${propsHtml}
</div>
<div class="card-actions">
</div>`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
${calibrationBtn}
</div>
</div>
`;
${calibrationBtn}`,
});
}
/* ── Editor open/close ────────────────────────────────────────── */

View File

@@ -10,6 +10,7 @@ import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); }
@@ -77,12 +78,13 @@ export function createDeviceCard(device) {
const ledCount = state.device_led_count || device.led_count;
return `
<div class="card" data-device-id="${device.id}">
<div class="card-top-actions">
${(device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}">${ICON_STOP_PLAIN}</button>` : ''}
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">&#x2715;</button>
</div>
return wrapCard({
dataAttr: 'data-device-id',
id: device.id,
topButtons: (device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}">${ICON_STOP_PLAIN}</button>` : '',
removeOnclick: `removeDevice('${device.id}')`,
removeTitle: t('device.button.remove'),
content: `
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
@@ -105,15 +107,12 @@ export function createDeviceCard(device) {
onchange="saveCardBrightness('${device.id}', this.value)"
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
</div>` : ''}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS}
</button>
</div>
</div>
`;
</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
${ICON_SETTINGS}
</button>`,
});
}
export async function turnOffDevice(deviceId) {

View File

@@ -19,6 +19,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_START, ICON_STOP, ICON_PAUSE,
ICON_LINK_SOURCE, ICON_PATTERN_TEMPLATE, ICON_FPS, ICON_PALETTE,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
class KCEditorModal extends Modal {
constructor() {
@@ -118,12 +119,13 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<div class="card-top-actions">
<button class="card-autostart-btn${target.auto_start ? ' active' : ''}" onclick="toggleTargetAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
</div>
return wrapCard({
dataAttr: 'data-kc-target-id',
id: target.id,
topButtons: `<button class="card-autostart-btn${target.auto_start ? ' active' : ''}" onclick="toggleTargetAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>`,
removeOnclick: `deleteKCTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
@@ -182,8 +184,8 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
<div class="timing-breakdown" data-tm="timing"></div>
` : ''}
</div>
` : ''}
<div class="card-actions">
` : ''}`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
${ICON_STOP}
@@ -201,10 +203,8 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
${ICON_EDIT}
</button>
</div>
</div>
`;
</button>`,
});
}
// ===== KEY COLORS TEST =====

View File

@@ -19,6 +19,7 @@ import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
class PatternTemplateModal extends Modal {
constructor() {
@@ -52,22 +53,24 @@ const patternModal = new PatternTemplateModal();
export function createPatternTemplateCard(pt) {
const rectCount = (pt.rectangles || []).length;
const desc = pt.description ? `<div class="template-description">${escapeHtml(pt.description)}</div>` : '';
return `
<div class="template-card" data-pattern-template-id="${pt.id}">
<button class="card-remove-btn" onclick="deletePatternTemplate('${pt.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-pattern-template-id',
id: pt.id,
removeOnclick: `deletePatternTemplate('${pt.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<span class="template-name">${ICON_PATTERN_TEMPLATE} ${escapeHtml(pt.name)}</span>
</div>
${desc}
<div class="stream-card-props">
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="template-card-actions">
</div>`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="clonePatternTemplate('${pt.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
export async function showPatternTemplateEditor(templateId = null, cloneData = null) {

View File

@@ -45,6 +45,7 @@ import {
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
@@ -1184,29 +1185,35 @@ function renderPictureSourcesList(streams) {
</div>`;
}
return `
<div class="template-card" data-stream-id="${stream.id}">
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-stream-id',
id: stream.id,
removeOnclick: `deleteStream('${stream.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
</div>
${detailsHtml}
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
<div class="template-card-actions">
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneStream('${stream.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderCaptureTemplateCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const configEntries = Object.entries(template.engine_config);
return `
<div class="template-card" data-template-id="${template.id}">
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-template-id',
id: template.id,
removeOnclick: `deleteTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
@@ -1227,14 +1234,12 @@ function renderPictureSourcesList(streams) {
`).join('')}
</table>
</details>
` : ''}
<div class="template-card-actions">
` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCaptureTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderPPTemplateCard = (tmpl) => {
@@ -1243,21 +1248,23 @@ function renderPictureSourcesList(streams) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">→</span>')}</div>`;
}
return `
<div class="template-card" data-pp-template-id="${tmpl.id}">
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-pp-template-id',
id: tmpl.id,
removeOnclick: `deletePPTemplate('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
<div class="template-card-actions">
${filterChainHtml}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
@@ -1305,28 +1312,34 @@ function renderPictureSourcesList(streams) {
propsHtml = `<span class="stream-card-prop">${devLabel} #${devIdx}</span>${tplBadge}`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteAudioSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteAudioSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="testAudioSource('${src.id}')" title="${t('audio_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="editAudioSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const renderAudioTemplateCard = (template) => {
const configEntries = Object.entries(template.engine_config || {});
return `
<div class="template-card" data-audio-template-id="${template.id}">
<button class="card-remove-btn" onclick="deleteAudioTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-audio-template-id',
id: template.id,
removeOnclick: `deleteAudioTemplate('${template.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
</div>
@@ -1347,14 +1360,12 @@ function renderPictureSourcesList(streams) {
`).join('')}
</table>
</details>
` : ''}
<div class="template-card-actions">
` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="showTestAudioTemplateModal('${template.id}')" title="${t('audio_template.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneAudioTemplate('${template.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="editAudioTemplate('${template.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const panels = tabs.map(tab => {

View File

@@ -23,6 +23,7 @@ import {
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { CardSection } from '../core/card-sections.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
@@ -850,12 +851,13 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
}
return `
<div class="card" data-target-id="${target.id}">
<div class="card-top-actions">
<button class="card-autostart-btn${target.auto_start ? ' active' : ''}" onclick="toggleTargetAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>
<button class="card-remove-btn" onclick="deleteTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
</div>
return wrapCard({
dataAttr: 'data-target-id',
id: target.id,
topButtons: `<button class="card-autostart-btn${target.auto_start ? ' active' : ''}" onclick="toggleTargetAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">&#x2605;</button>`,
removeOnclick: `deleteTarget('${target.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
@@ -908,8 +910,8 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<div id="led-preview-panel-${target.id}" class="led-preview-panel" style="display:${ledPreviewWebSockets[target.id] ? '' : 'none'}">
<canvas id="led-preview-canvas-${target.id}" class="led-preview-canvas"></canvas>
<span id="led-preview-brightness-${target.id}" class="led-preview-brightness" style="display:none"${bvsId ? ' data-has-bvs="1"' : ''}></span>
</div>
<div class="card-actions">
</div>`,
actions: `
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
${ICON_STOP}
@@ -938,10 +940,8 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
${ICON_OVERLAY}
</button>
`) : ''}
</div>
</div>
`;
`) : ''}`,
});
}
async function _targetAction(action) {

View File

@@ -21,6 +21,7 @@ import {
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { loadPictureSources } from './streams.js';
export { getValueSourceIcon };
@@ -522,21 +523,23 @@ export function createValueSourceCard(src) {
`;
}
return `
<div class="template-card" data-id="${src.id}">
<button class="card-remove-btn" onclick="deleteValueSource('${src.id}')" title="${t('common.delete')}">&#x2715;</button>
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: src.id,
removeOnclick: `deleteValueSource('${src.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
</div>
<div class="stream-card-props">${propsHtml}</div>
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}
<div class="template-card-actions">
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneValueSource('${src.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
</div>
</div>
`;
<button class="btn btn-icon btn-secondary" onclick="editValueSource('${src.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Helpers ───────────────────────────────────────────────────