Add Adalight serial LED device support with per-type discovery and capability-based UI

Implements the second device provider (Adalight) for Arduino-based serial LED controllers,
validating the LEDDeviceProvider abstraction. Adds serial port auto-discovery, per-type
discovery caching with lazy-load, capability-driven UI (brightness control, manual LED count,
standby), and serial port combobox in both Add Device and General Settings modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 15:55:42 +03:00
parent 242718a9a9
commit 1612c04c90
13 changed files with 678 additions and 76 deletions

View File

@@ -649,7 +649,7 @@ function createDeviceCard(device) {
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${device.url ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : ''}
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">🌐</span></a>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
${healthLabel}
</div>
</div>
@@ -659,6 +659,7 @@ function createDeviceCard(device) {
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
</div>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
<input type="range" class="brightness-slider" min="0" max="255"
value="${_deviceBrightnessCache[device.id] ?? 0}" data-device-brightness="${device.id}"
@@ -666,7 +667,7 @@ 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>` : ''}
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
@@ -729,17 +730,46 @@ async function showSettings(deviceId) {
}
const device = await deviceResponse.json();
const isAdalight = device.device_type === 'adalight';
// Populate fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
document.getElementById('settings-health-interval').value = 30;
// Toggle URL vs serial port field
const urlGroup = document.getElementById('settings-url-group');
const serialGroup = document.getElementById('settings-serial-port-group');
if (isAdalight) {
urlGroup.style.display = 'none';
document.getElementById('settings-device-url').removeAttribute('required');
serialGroup.style.display = '';
// Populate serial port dropdown via discovery
_populateSettingsSerialPorts(device.url);
} else {
urlGroup.style.display = '';
document.getElementById('settings-device-url').setAttribute('required', '');
document.getElementById('settings-device-url').value = device.url;
serialGroup.style.display = 'none';
}
// Show LED count field for devices with manual_led_count capability
const caps = device.capabilities || [];
const ledCountGroup = document.getElementById('settings-led-count-group');
if (caps.includes('manual_led_count')) {
ledCountGroup.style.display = '';
document.getElementById('settings-led-count').value = device.led_count || '';
} else {
ledCountGroup.style.display = 'none';
}
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
led_count: String(device.led_count || ''),
device_type: device.device_type,
capabilities: caps,
state_check_interval: '30',
};
@@ -759,11 +789,21 @@ async function showSettings(deviceId) {
}
}
function _getSettingsUrl() {
if (settingsInitialValues.device_type === 'adalight') {
return document.getElementById('settings-serial-port').value;
}
return document.getElementById('settings-device-url').value.trim();
}
function isSettingsDirty() {
const ledCountDirty = (settingsInitialValues.capabilities || []).includes('manual_led_count')
&& document.getElementById('settings-led-count').value !== settingsInitialValues.led_count;
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
_getSettingsUrl() !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
ledCountDirty
);
}
@@ -787,7 +827,7 @@ async function closeDeviceSettingsModal() {
async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const url = _getSettingsUrl();
const error = document.getElementById('settings-error');
// Validation
@@ -798,11 +838,16 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url)
// Update device info (name, url, optionally led_count)
const body = { name, url };
const ledCountInput = document.getElementById('settings-led-count');
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10);
}
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify(body)
});
if (deviceResponse.status === 401) {
@@ -874,6 +919,174 @@ async function fetchDeviceBrightness(deviceId) {
// Add device modal
let _discoveryScanRunning = false;
let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
function onDeviceTypeChanged() {
const deviceType = document.getElementById('device-type').value;
const urlGroup = document.getElementById('device-url-group');
const urlInput = document.getElementById('device-url');
const serialGroup = document.getElementById('device-serial-port-group');
const serialSelect = document.getElementById('device-serial-port');
const ledCountGroup = document.getElementById('device-led-count-group');
const discoverySection = document.getElementById('discovery-section');
if (deviceType === 'adalight') {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = '';
serialSelect.setAttribute('required', '');
ledCountGroup.style.display = '';
// Hide discovery list — serial port dropdown replaces it
if (discoverySection) discoverySection.style.display = 'none';
// Populate from cache or show placeholder (lazy-load on focus)
if (deviceType in _discoveryCache) {
_populateSerialPortDropdown(_discoveryCache[deviceType]);
} else {
serialSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('device.serial_port.hint') || 'Click to discover ports...';
opt.disabled = true;
serialSelect.appendChild(opt);
}
} else {
urlGroup.style.display = '';
urlInput.setAttribute('required', '');
serialGroup.style.display = 'none';
serialSelect.removeAttribute('required');
ledCountGroup.style.display = 'none';
// Show cached results or trigger scan for WLED
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
}
}
function _renderDiscoveryList() {
const selectedType = document.getElementById('device-type').value;
const devices = _discoveryCache[selectedType];
// Adalight: populate serial port dropdown instead of discovery list
if (selectedType === 'adalight') {
_populateSerialPortDropdown(devices || []);
return;
}
// WLED and others: render discovery list cards
const list = document.getElementById('discovery-list');
const empty = document.getElementById('discovery-empty');
const section = document.getElementById('discovery-section');
if (!list || !section) return;
list.innerHTML = '';
if (!devices) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
if (devices.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
devices.forEach(device => {
const card = document.createElement('div');
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
const meta = [device.ip];
if (device.led_count) meta.push(device.led_count + ' LEDs');
if (device.version) meta.push('v' + device.version);
card.innerHTML = `
<div class="discovery-item-info">
<strong>${escapeHtml(device.name)}</strong>
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
</div>
${device.already_added
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
: ''}
`;
if (!device.already_added) {
card.onclick = () => selectDiscoveredDevice(device);
}
list.appendChild(card);
});
}
function _populateSerialPortDropdown(devices) {
const select = document.getElementById('device-serial-port');
select.innerHTML = '';
if (devices.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('device.serial_port.none') || 'No serial ports found';
opt.disabled = true;
select.appendChild(opt);
return;
}
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.url;
opt.textContent = device.name;
if (device.already_added) {
opt.textContent += ' (' + t('device.scan.already_added') + ')';
}
select.appendChild(opt);
});
}
function onSerialPortFocus() {
// Lazy-load: trigger discovery when user opens the serial port dropdown
if (!('adalight' in _discoveryCache)) {
scanForDevices('adalight');
}
}
async function _populateSettingsSerialPorts(currentUrl) {
const select = document.getElementById('settings-serial-port');
select.innerHTML = '';
// Show loading placeholder
const loadingOpt = document.createElement('option');
loadingOpt.value = currentUrl;
loadingOpt.textContent = currentUrl + ' ⏳';
select.appendChild(loadingOpt);
try {
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, {
headers: getHeaders()
});
if (!resp.ok) return;
const data = await resp.json();
const devices = data.devices || [];
select.innerHTML = '';
// Always include current port even if not discovered
let currentFound = false;
devices.forEach(device => {
const opt = document.createElement('option');
opt.value = device.url;
opt.textContent = device.name;
if (device.url === currentUrl) currentFound = true;
select.appendChild(opt);
});
if (!currentFound) {
const opt = document.createElement('option');
opt.value = currentUrl;
opt.textContent = currentUrl;
select.insertBefore(opt, select.firstChild);
}
select.value = currentUrl;
} catch (err) {
console.error('Failed to discover serial ports:', err);
// Keep the current URL as fallback
}
}
function showAddDevice() {
const modal = document.getElementById('add-device-modal');
@@ -881,6 +1094,7 @@ function showAddDevice() {
const error = document.getElementById('add-device-error');
form.reset();
error.style.display = 'none';
_discoveryCache = {};
// Reset discovery section
const section = document.getElementById('discovery-section');
if (section) {
@@ -889,13 +1103,14 @@ function showAddDevice() {
document.getElementById('discovery-empty').style.display = 'none';
document.getElementById('discovery-loading').style.display = 'none';
}
// Reset serial port dropdown
document.getElementById('device-serial-port').innerHTML = '';
const scanBtn = document.getElementById('scan-network-btn');
if (scanBtn) scanBtn.disabled = false;
modal.style.display = 'flex';
lockBody();
onDeviceTypeChanged();
setTimeout(() => document.getElementById('device-name').focus(), 100);
// Auto-start discovery on open
scanForDevices();
}
function closeAddDeviceModal() {
@@ -904,9 +1119,12 @@ function closeAddDeviceModal() {
unlockBody();
}
async function scanForDevices() {
if (_discoveryScanRunning) return;
_discoveryScanRunning = true;
async function scanForDevices(forceType) {
const scanType = forceType || document.getElementById('device-type')?.value || 'wled';
// Per-type guard: prevent duplicate scans for the same type
if (_discoveryScanRunning === scanType) return;
_discoveryScanRunning = scanType;
const loading = document.getElementById('discovery-loading');
const list = document.getElementById('discovery-list');
@@ -914,14 +1132,26 @@ async function scanForDevices() {
const section = document.getElementById('discovery-section');
const scanBtn = document.getElementById('scan-network-btn');
section.style.display = 'block';
loading.style.display = 'flex';
list.innerHTML = '';
empty.style.display = 'none';
if (scanType === 'adalight') {
// Show loading in the serial port dropdown
const select = document.getElementById('device-serial-port');
select.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '⏳';
opt.disabled = true;
select.appendChild(opt);
} else {
// Show the discovery section with loading spinner
section.style.display = 'block';
loading.style.display = 'flex';
list.innerHTML = '';
empty.style.display = 'none';
}
if (scanBtn) scanBtn.disabled = true;
try {
const response = await fetch(`${API_BASE}/devices/discover?timeout=3`, {
const response = await fetch(`${API_BASE}/devices/discover?timeout=3&device_type=${encodeURIComponent(scanType)}`, {
headers: getHeaders()
});
@@ -931,54 +1161,46 @@ async function scanForDevices() {
if (scanBtn) scanBtn.disabled = false;
if (!response.ok) {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
if (scanType !== 'adalight') {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
return;
}
const data = await response.json();
_discoveryCache[scanType] = data.devices || [];
if (data.devices.length === 0) {
empty.style.display = 'block';
return;
// Only render if the user is still on this type
const currentType = document.getElementById('device-type')?.value;
if (currentType === scanType) {
_renderDiscoveryList();
}
data.devices.forEach(device => {
const card = document.createElement('div');
card.className = 'discovery-item' + (device.already_added ? ' discovery-item--added' : '');
const meta = [device.ip];
if (device.led_count) meta.push(device.led_count + ' LEDs');
if (device.version) meta.push('v' + device.version);
card.innerHTML = `
<div class="discovery-item-info">
<strong>${escapeHtml(device.name)}</strong>
<small>${escapeHtml(meta.join(' \u00b7 '))}</small>
</div>
${device.already_added
? '<span class="discovery-badge">' + t('device.scan.already_added') + '</span>'
: ''}
`;
if (!device.already_added) {
card.onclick = () => selectDiscoveredDevice(device);
}
list.appendChild(card);
});
} catch (err) {
loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false;
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
if (scanType !== 'adalight') {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
console.error('Device scan failed:', err);
} finally {
_discoveryScanRunning = false;
if (_discoveryScanRunning === scanType) {
_discoveryScanRunning = false;
}
}
}
function selectDiscoveredDevice(device) {
document.getElementById('device-name').value = device.name;
document.getElementById('device-url').value = device.url;
const typeSelect = document.getElementById('device-type');
if (typeSelect) typeSelect.value = device.device_type;
onDeviceTypeChanged();
if (device.device_type === 'adalight') {
document.getElementById('device-serial-port').value = device.url;
} else {
document.getElementById('device-url').value = device.url;
}
showToast(t('device.scan.selected'), 'info');
}
@@ -986,7 +1208,10 @@ async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value.trim();
const url = document.getElementById('device-url').value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled';
const url = deviceType === 'adalight'
? document.getElementById('device-serial-port').value
: document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error');
if (!name || !url) {
@@ -996,8 +1221,11 @@ async function handleAddDevice(event) {
}
try {
const deviceType = document.getElementById('device-type')?.value || 'wled';
const body = { name, url, device_type: deviceType };
const ledCountInput = document.getElementById('device-led-count');
if (ledCountInput && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10);
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
@@ -4274,7 +4502,9 @@ async function loadTargetsTab() {
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
fetchDeviceBrightness(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
fetchDeviceBrightness(device.id);
}
});
// Manage KC WebSockets: connect for processing, disconnect for stopped

View File

@@ -237,13 +237,30 @@
<input type="text" id="settings-device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<div class="form-group" id="settings-url-group">
<div class="label-row">
<label for="settings-device-url" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of the device</small>
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<input type="text" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="settings-serial-port-group" style="display: none;">
<div class="label-row">
<label for="settings-serial-port" data-i18n="device.serial_port">Serial Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
<select id="settings-serial-port"></select>
</div>
<div class="form-group" id="settings-led-count-group" style="display: none;">
<div class="label-row">
<label for="settings-led-count" data-i18n="device.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<input type="number" id="settings-led-count" min="1" max="10000">
</div>
<div class="form-group">
@@ -582,7 +599,7 @@
</div>
<div id="discovery-list" class="discovery-list"></div>
<div id="discovery-empty" style="display: none;">
<small data-i18n="device.scan.empty">No WLED devices found on the network</small>
<small data-i18n="device.scan.empty">No devices found</small>
</div>
<hr class="modal-divider">
</div>
@@ -593,21 +610,38 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
<select id="device-type">
<select id="device-type" onchange="onDeviceTypeChanged()">
<option value="wled">WLED</option>
<option value="adalight">Adalight</option>
</select>
</div>
<div class="form-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
<div class="form-group">
<div class="form-group" id="device-url-group">
<div class="label-row">
<label for="device-url" data-i18n="device.url">URL:</label>
<label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
<small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="device-serial-port-group" style="display: none;">
<div class="label-row">
<label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
<select id="device-serial-port" onfocus="onSerialPortFocus()"></select>
</div>
<div class="form-group" id="device-led-count-group" style="display: none;">
<div class="label-row">
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<input type="number" id="device-led-count" min="1" max="10000" placeholder="60">
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>

View File

@@ -102,12 +102,16 @@
"devices.wled_note_webui": "(open your device's IP in a browser).",
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
"device.scan": "Auto Discovery",
"device.scan.empty": "No WLED devices found on the network",
"device.scan.empty": "No devices found",
"device.scan.error": "Network scan failed",
"device.scan.already_added": "Already added",
"device.scan.selected": "Device selected",
"device.type": "Device Type:",
"device.type.hint": "Select the type of LED controller",
"device.serial_port": "Serial Port:",
"device.serial_port.hint": "Select the COM port of the Adalight device",
"device.serial_port.none": "No serial ports found",
"device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)",
"device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)",
"device.name": "Device Name:",
"device.name.placeholder": "Living Room TV",

View File

@@ -102,12 +102,16 @@
"devices.wled_note_webui": "(откройте IP устройства в браузере).",
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
"device.scan": "Автопоиск",
"device.scan.empty": "WLED устройства не найдены в сети",
"device.scan.empty": "Устройства не найдены",
"device.scan.error": "Ошибка сканирования сети",
"device.scan.already_added": "Уже добавлено",
"device.scan.selected": "Устройство выбрано",
"device.type": "Тип устройства:",
"device.type.hint": "Выберите тип LED контроллера",
"device.serial_port": "Серийный порт:",
"device.serial_port.hint": "Выберите COM порт устройства Adalight",
"device.serial_port.none": "Серийные порты не найдены",
"device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)",
"device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)",
"device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной",

View File

@@ -397,6 +397,16 @@ section {
background: var(--border-color);
white-space: nowrap;
}
.discovery-type-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: var(--primary-color);
color: #fff;
font-weight: 600;
vertical-align: middle;
margin-right: 2px;
}
.modal-divider {
border: none;
border-top: 1px solid var(--border-color);
@@ -1244,7 +1254,7 @@ input:-webkit-autofill:focus {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
margin: 40px auto 20px;
margin: 40px auto 40px;
background: var(--card-bg);
border: 2px solid var(--border-color);
border-radius: 8px;