Improve WGC cleanup, add capture duration persistence, simplify test errors
Some checks failed
Validate / validate (push) Failing after 9s

- Add WGC engine with aggressive COM object cleanup (multiple GC passes)
- Persist capture test duration to localStorage
- Show only short error messages in snackbar (details in console)

Note: WGC cleanup still has issues with yellow border persistence - needs further investigation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 18:38:12 +03:00
parent 6c5608f5ea
commit e2508554cd
8 changed files with 1770 additions and 8 deletions

View File

@@ -164,12 +164,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// Show content now that translations are loaded
document.body.style.visibility = 'visible';
// Restore active tab
initTabs();
// Load API key from localStorage
apiKey = localStorage.getItem('wled_api_key');
// Restore active tab (after API key is loaded)
initTabs();
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
@@ -206,6 +206,31 @@ function getHeaders() {
return headers;
}
// Fetch wrapper that automatically includes auth headers
async function fetchWithAuth(url, options = {}) {
// Build full URL if relative path provided
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
// Merge auth headers with any custom headers
const headers = options.headers
? { ...getHeaders(), ...options.headers }
: getHeaders();
// Make request with merged options
return fetch(fullUrl, {
...options,
headers
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle 401 errors by showing login modal
function handle401Error() {
// Clear invalid API key
@@ -319,6 +344,9 @@ function switchTab(name) {
if (name === 'displays' && _cachedDisplays) {
requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays));
}
if (name === 'templates') {
loadCaptureTemplates();
}
}
function initTabs() {
@@ -709,10 +737,11 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) {
try {
// Fetch device data and displays in parallel
const [deviceResponse, displaysResponse] = await Promise.all([
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
if (deviceResponse.status === 401) {
@@ -747,6 +776,27 @@ async function showSettings(deviceId) {
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate capture template select
const templateSelect = document.getElementById('settings-capture-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Populate other fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
@@ -759,6 +809,7 @@ async function showSettings(deviceId) {
url: device.url,
display_index: String(device.settings.display_index ?? 0),
state_check_interval: String(device.settings.state_check_interval || 30),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
@@ -782,7 +833,8 @@ function isSettingsDirty() {
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id
);
}
@@ -809,6 +861,7 @@ async function saveDeviceSettings() {
const url = document.getElementById('settings-device-url').value.trim();
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const capture_template_id = document.getElementById('settings-capture-template').value;
const error = document.getElementById('settings-error');
// Validation
@@ -819,11 +872,11 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url)
// Update device info (name, url, capture_template_id)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify({ name, url, capture_template_id })
});
if (deviceResponse.status === 401) {
@@ -2126,3 +2179,513 @@ function handleTutorialKey(e) {
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
}
// ===========================
// Capture Templates Functions
// ===========================
let availableEngines = [];
let currentEditingTemplateId = null;
// Load and render capture templates
async function loadCaptureTemplates() {
try {
const response = await fetchWithAuth('/capture-templates');
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
const data = await response.json();
renderTemplatesList(data.templates || []);
} catch (error) {
console.error('Error loading capture templates:', error);
document.getElementById('templates-list').innerHTML = `
<div class="error-message">${t('templates.error.load')}: ${error.message}</div>
`;
}
}
// Render templates list
function renderTemplatesList(templates) {
const container = document.getElementById('templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
return;
}
container.innerHTML = templates.map(template => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
: '';
return `
<div class="template-card" data-template-id="${template.id}">
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
${defaultBadge}
</div>
<div class="template-config">
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
</div>
${Object.keys(template.engine_config).length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<pre>${JSON.stringify(template.engine_config, null, 2)}</pre>
</details>
` : `
<div class="template-no-config">${t('templates.config.none')}</div>
`}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
${!template.is_default ? `
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
<button class="btn btn-icon btn-danger" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">
🗑️
</button>
` : ''}
</div>
</div>
`;
}).join('') + `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
}
// Get engine icon
function getEngineIcon(engineType) {
return '🖥️';
}
// Show add template modal
async function showAddTemplateModal() {
currentEditingTemplateId = null;
document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset();
document.getElementById('template-id').value = '';
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Load available engines
await loadAvailableEngines();
document.getElementById('template-modal').style.display = 'flex';
}
// Edit template
async function editTemplate(templateId) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) {
throw new Error(`Failed to load template: ${response.status}`);
}
const template = await response.json();
currentEditingTemplateId = templateId;
document.getElementById('template-modal-title').textContent = t('templates.edit');
document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name;
// Load available engines
await loadAvailableEngines();
// Set engine and load config
document.getElementById('template-engine').value = template.engine_type;
await onEngineChange();
// Populate engine config fields
populateEngineConfig(template.engine_config);
// Load displays for test
await loadDisplaysForTest();
document.getElementById('template-test-results').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
document.getElementById('template-modal').style.display = 'flex';
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
// Close template modal
function closeTemplateModal() {
document.getElementById('template-modal').style.display = 'none';
currentEditingTemplateId = null;
}
// Update capture duration and save to localStorage
function updateCaptureDuration(value) {
document.getElementById('test-template-duration-value').textContent = value;
localStorage.setItem('capture_duration', value);
}
// Restore capture duration from localStorage
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration');
const durationValue = document.getElementById('test-template-duration-value');
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
// Show test template modal
async function showTestTemplateModal(templateId) {
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
const template = templates.templates.find(t => t.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
// Store current template for testing
window.currentTestingTemplate = template;
// Load displays
await loadDisplaysForTest();
// Restore last used capture duration
restoreCaptureDuration();
// Reset results
document.getElementById('test-template-results').style.display = 'none';
// Show modal
document.getElementById('test-template-modal').style.display = 'flex';
}
// Close test template modal
function closeTestTemplateModal() {
document.getElementById('test-template-modal').style.display = 'none';
window.currentTestingTemplate = null;
}
// Load available engines
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) {
throw new Error(`Failed to load engines: ${response.status}`);
}
const data = await response.json();
availableEngines = data.engines || [];
const select = document.getElementById('template-engine');
select.innerHTML = `<option value="">${t('templates.engine.select')}</option>`;
availableEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
// Handle engine selection change
async function onEngineChange() {
const engineType = document.getElementById('template-engine').value;
const configSection = document.getElementById('engine-config-section');
const configFields = document.getElementById('engine-config-fields');
if (!engineType) {
configSection.style.display = 'none';
return;
}
const engine = availableEngines.find(e => e.type === engineType);
if (!engine) {
configSection.style.display = 'none';
return;
}
// Show availability hint
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
// Render config fields based on default_config
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configFields.innerHTML = `<p class="text-muted">${t('templates.config.none')}</p>`;
} else {
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const fieldHtml = `
<div class="form-group">
<label for="config-${key}">${key}:</label>
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
<small class="form-hint">${t('templates.config.default')}: ${JSON.stringify(value)}</small>
</div>
`;
configFields.innerHTML += fieldHtml;
});
}
configSection.style.display = 'block';
}
// Populate engine config fields with values
function populateEngineConfig(config) {
Object.entries(config).forEach(([key, value]) => {
const field = document.getElementById(`config-${key}`);
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
// Collect engine config from form
function collectEngineConfig() {
const config = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach(field => {
const key = field.dataset.configKey;
let value = field.value;
// Type conversion
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
// Load displays for test selector
async function loadDisplaysForTest() {
try {
const response = await fetchWithAuth('/config/displays');
if (!response.ok) {
throw new Error(`Failed to load displays: ${response.status}`);
}
const displaysData = await response.json();
const select = document.getElementById('test-template-display');
select.innerHTML = `<option value="">${t('templates.test.display.select')}</option>`;
(displaysData.displays || []).forEach(display => {
const option = document.createElement('option');
option.value = display.index;
option.textContent = `Display ${display.index} (${display.width}x${display.height})`;
if (display.is_primary) {
option.textContent += ' - Primary';
}
select.appendChild(option);
});
} catch (error) {
console.error('Error loading displays:', error);
}
}
// Run template test
async function runTemplateTest() {
if (!window.currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = document.getElementById('test-template-display').value;
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = window.currentTestingTemplate;
const resultsDiv = document.getElementById('test-template-results');
// Show loading state without destroying the structure
const loadingDiv = document.createElement('div');
loadingDiv.className = 'loading';
loadingDiv.textContent = t('templates.test.running');
loadingDiv.style.position = 'absolute';
loadingDiv.style.inset = '0';
loadingDiv.style.background = 'var(--bg-primary)';
loadingDiv.style.display = 'flex';
loadingDiv.style.alignItems = 'center';
loadingDiv.style.justifyContent = 'center';
loadingDiv.style.zIndex = '10';
loadingDiv.id = 'test-loading-overlay';
// Remove old loading overlay if exists
const oldLoading = document.getElementById('test-loading-overlay');
if (oldLoading) oldLoading.remove();
resultsDiv.style.display = 'block';
resultsDiv.style.position = 'relative';
resultsDiv.appendChild(loadingDiv);
try {
const response = await fetchWithAuth('/capture-templates/test', {
method: 'POST',
body: JSON.stringify({
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
displayTestResults(result);
} catch (error) {
console.error('Error running test:', error);
// Remove loading overlay
const loadingOverlay = document.getElementById('test-loading-overlay');
if (loadingOverlay) loadingOverlay.remove();
// Show short error in snack, details are in console
showToast(t('templates.test.error.failed'), 'error');
}
}
// Display test results
function displayTestResults(result) {
const resultsDiv = document.getElementById('test-template-results');
// Remove loading overlay
const loadingOverlay = document.getElementById('test-loading-overlay');
if (loadingOverlay) loadingOverlay.remove();
// Full capture preview
const previewImg = document.getElementById('test-template-preview-image');
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Capture preview" style="max-width: 100%; border-radius: 4px;">`;
// Performance stats
document.getElementById('test-template-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
document.getElementById('test-template-frame-count').textContent = result.performance.frame_count;
document.getElementById('test-template-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
document.getElementById('test-template-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
// Show results
resultsDiv.style.display = 'block';
}
// Save template
async function saveTemplate() {
const templateId = document.getElementById('template-id').value;
const name = document.getElementById('template-name').value.trim();
const engineType = document.getElementById('template-engine').value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const engineConfig = collectEngineConfig();
const payload = {
name,
engine_type: engineType,
engine_config: engineConfig
};
try {
let response;
if (templateId) {
// Update existing template
response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
// Create new template
response = await fetchWithAuth('/capture-templates', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
closeTemplateModal();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error').textContent = error.message;
document.getElementById('template-error').style.display = 'block';
}
}
// Delete template
async function deleteTemplate(templateId) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}

View File

@@ -36,6 +36,7 @@
<div class="tab-bar">
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
<button class="tab-btn" data-tab="displays" onclick="switchTab('displays')"><span data-i18n="displays.layout">🖥️ Displays</span></button>
<button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button>
</div>
<div class="tab-panel active" id="tab-devices">
@@ -60,6 +61,17 @@
</div>
<div id="displays-list" style="display: none;"></div>
</div>
<div class="tab-panel" id="tab-templates">
<p class="section-tip">
<span data-i18n="templates.description">
Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.
</span>
</p>
<div id="templates-list" class="templates-grid">
<div class="loading" data-i18n="templates.loading">Loading templates...</div>
</div>
</div>
</div>
<footer class="app-footer">
@@ -222,6 +234,12 @@
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
</div>
<div class="form-group">
<label for="settings-capture-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="settings-capture-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
@@ -320,6 +338,102 @@
</div>
</div>
<!-- Template Modal -->
<div id="template-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="template-modal-title" data-i18n="templates.add">Add Capture Template</h2>
<button class="modal-close-btn" onclick="closeTemplateModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<input type="hidden" id="template-id">
<form id="template-form">
<div class="form-group">
<label for="template-name" data-i18n="templates.name">Template Name:</label>
<input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
</div>
<div class="form-group">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<select id="template-engine" onchange="onEngineChange()" required>
<option value="" data-i18n="templates.engine.select">Select an engine...</option>
</select>
<small id="engine-availability-hint" class="form-hint" style="display: none;"></small>
</div>
<div id="engine-config-section" style="display: none;">
<h3 data-i18n="templates.config">Engine Configuration</h3>
<div id="engine-config-fields"></div>
</div>
<div id="template-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeTemplateModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveTemplate()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Test Template Modal -->
<div id="test-template-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="templates.test.title">Test Capture Template</h2>
<button class="modal-close-btn" onclick="closeTestTemplateModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="test-template-display" data-i18n="templates.test.display">Display:</label>
<select id="test-template-display">
<option value="" data-i18n="templates.test.display.select">Select display...</option>
</select>
</div>
<div class="form-group">
<label for="test-template-duration">
<span data-i18n="templates.test.duration">Capture Duration (s):</span>
<span id="test-template-duration-value">5</span>
</label>
<input type="range" id="test-template-duration" min="1" max="10" step="1" value="5" oninput="updateCaptureDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
<span data-i18n="templates.test.run">🧪 Run Test</span>
</button>
<div id="test-template-results" style="display: none; margin-top: 16px;">
<div class="test-results-container">
<div class="test-preview-section">
<div id="test-template-preview-image" class="test-preview-image"></div>
</div>
<div class="test-performance-section">
<div class="test-performance-stats">
<div class="stat-item">
<span data-i18n="templates.test.results.duration">Duration:</span>
<strong id="test-template-actual-duration">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.frame_count">Frames:</span>
<strong id="test-template-frame-count">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
<strong id="test-template-actual-fps">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
<strong id="test-template-avg-capture-time">-</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">