Add per-tab tutorials, profile expand/collapse, and fix card animation
- Add sub-tutorials for Dashboard, Targets, Sources, and Profiles tabs with ? trigger buttons, en/ru/zh translations, and hidden-ancestor skip via offsetParent check - Add expand/collapse all buttons to Profiles tab toolbar - Move dashboard poll slider from section header to toolbar - Fix cardEnter animation forcing opacity:1 on disabled profile cards - Use data-card-section selectors instead of data-cs-toggle to avoid z-index misalignment during tutorial spotlight - Add tutorial sync convention to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,15 @@ For range sliders, display the current value **inside the label** (not in a sepa
|
||||
|
||||
Do **not** use a `range-with-value` wrapper div.
|
||||
|
||||
### Tutorials
|
||||
|
||||
The app has an interactive tutorial system (`static/js/features/tutorials.js`) with a generic engine, spotlight overlay, tooltip positioning, and keyboard navigation. Tutorials exist for:
|
||||
- **Getting started** (header-level walkthrough of all tabs and controls)
|
||||
- **Per-tab tutorials** (Dashboard, Targets, Sources, Profiles) triggered by `?` buttons
|
||||
- **Device card tutorial** and **Calibration tutorial** (context-specific)
|
||||
|
||||
When adding **new tabs, sections, or major UI elements**, update the corresponding tutorial step array in `tutorials.js` and add `tour.*` i18n keys to all 3 locale files (`en.json`, `ru.json`, `zh.json`).
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Always test changes before marking as complete
|
||||
|
||||
@@ -69,7 +69,7 @@ section {
|
||||
/* ── Card entrance animation ── */
|
||||
@keyframes cardEnter {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.card-enter {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './features/displays.js';
|
||||
import {
|
||||
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
|
||||
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startProfilesTutorial,
|
||||
closeTutorial, tutorialNext, tutorialPrev,
|
||||
} from './features/tutorials.js';
|
||||
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
loadProfiles, openProfileEditor, closeProfileEditorModal,
|
||||
saveProfileEditor, addProfileCondition,
|
||||
toggleProfileEnabled, toggleProfileTargets, deleteProfile,
|
||||
expandAllProfileSections, collapseAllProfileSections,
|
||||
} from './features/profiles.js';
|
||||
|
||||
// Layer 5: device-discovery, targets
|
||||
@@ -175,6 +177,10 @@ Object.assign(window, {
|
||||
startCalibrationTutorial,
|
||||
startDeviceTutorial,
|
||||
startGettingStartedTutorial,
|
||||
startDashboardTutorial,
|
||||
startTargetsTutorial,
|
||||
startSourcesTutorial,
|
||||
startProfilesTutorial,
|
||||
closeTutorial,
|
||||
tutorialNext,
|
||||
tutorialPrev,
|
||||
@@ -298,6 +304,8 @@ Object.assign(window, {
|
||||
toggleProfileEnabled,
|
||||
toggleProfileTargets,
|
||||
deleteProfile,
|
||||
expandAllProfileSections,
|
||||
collapseAllProfileSections,
|
||||
|
||||
// device-discovery
|
||||
onDeviceTypeChanged,
|
||||
|
||||
@@ -274,7 +274,7 @@ function _updateProfilesInPlace(profiles) {
|
||||
|
||||
function _renderPollIntervalSelect() {
|
||||
const sec = Math.round(dashboardPollInterval / 1000);
|
||||
return `<span class="dashboard-poll-wrap" onclick="event.stopPropagation()"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
|
||||
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
|
||||
}
|
||||
|
||||
let _pollDebounce = null;
|
||||
@@ -518,9 +518,10 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
// First load: build everything in one innerHTML to avoid flicker
|
||||
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
|
||||
const pollSelect = _renderPollIntervalSelect();
|
||||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">?</button></span></div>`;
|
||||
if (isFirstLoad) {
|
||||
container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section">
|
||||
${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)}
|
||||
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
|
||||
${_sectionHeader('perf', t('dashboard.section.performance'), '')}
|
||||
${_sectionContent('perf', renderPerfSection())}
|
||||
</div>
|
||||
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
||||
|
||||
@@ -76,11 +76,20 @@ export async function loadProfiles() {
|
||||
}
|
||||
}
|
||||
|
||||
export function expandAllProfileSections() {
|
||||
CardSection.expandAll([csProfiles]);
|
||||
}
|
||||
|
||||
export function collapseAllProfileSections() {
|
||||
CardSection.collapseAll([csProfiles]);
|
||||
}
|
||||
|
||||
function renderProfiles(profiles, runningTargetIds = new Set()) {
|
||||
const container = document.getElementById('profiles-content');
|
||||
|
||||
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) })));
|
||||
container.innerHTML = csProfiles.render(items);
|
||||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">?</button></span></div>`;
|
||||
container.innerHTML = toolbar + csProfiles.render(items);
|
||||
csProfiles.bind();
|
||||
|
||||
// Localize data-i18n elements within the profiles container only
|
||||
|
||||
@@ -1301,7 +1301,7 @@ function renderPictureSourcesList(streams) {
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
|
||||
`<button class="stream-tab-btn${tab.key === activeTab ? ' active' : ''}" data-stream-tab="${tab.key}" onclick="switchStreamTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
||||
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
|
||||
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllStreamSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" title="${t('tour.restart')}">?</button></span></div>`;
|
||||
|
||||
const renderAudioSourceCard = (src) => {
|
||||
const isMono = src.source_type === 'mono';
|
||||
|
||||
@@ -576,7 +576,7 @@ export async function loadTargetsTab() {
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
||||
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
|
||||
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllTargetSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" title="${t('section.collapse_all')}">⊟</button></span></div>`;
|
||||
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllTargetSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" title="${t('tour.restart')}">?</button></span></div>`;
|
||||
|
||||
// Use window.createPatternTemplateCard to avoid circular import
|
||||
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
||||
|
||||
@@ -33,6 +33,44 @@ const gettingStartedSteps = [
|
||||
{ selector: '#locale-select', textKey: 'tour.language', position: 'bottom' }
|
||||
];
|
||||
|
||||
const dashboardTutorialSteps = [
|
||||
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' },
|
||||
{ selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' },
|
||||
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' },
|
||||
{ selector: '[data-dashboard-section="profiles"]', textKey: 'tour.dash.profiles', position: 'bottom' }
|
||||
];
|
||||
|
||||
const targetsTutorialSteps = [
|
||||
{ selector: '[data-target-sub-tab="led"]', textKey: 'tour.tgt.led_tab', position: 'bottom' },
|
||||
{ selector: '[data-card-section="led-devices"]', textKey: 'tour.tgt.devices', position: 'bottom' },
|
||||
{ selector: '[data-card-section="led-css"]', textKey: 'tour.tgt.css', position: 'bottom' },
|
||||
{ selector: '[data-card-section="led-targets"]', textKey: 'tour.tgt.targets', position: 'bottom' },
|
||||
{ selector: '[data-target-sub-tab="key_colors"]', textKey: 'tour.tgt.kc_tab', position: 'bottom' }
|
||||
];
|
||||
|
||||
const sourcesTourSteps = [
|
||||
{ selector: '[data-stream-tab="raw"]', textKey: 'tour.src.raw', position: 'bottom' },
|
||||
{ selector: '[data-card-section="raw-templates"]', textKey: 'tour.src.templates', position: 'bottom' },
|
||||
{ selector: '[data-stream-tab="static_image"]', textKey: 'tour.src.static', position: 'bottom' },
|
||||
{ selector: '[data-stream-tab="processed"]', textKey: 'tour.src.processed', position: 'bottom' },
|
||||
{ selector: '[data-stream-tab="audio"]', textKey: 'tour.src.audio', position: 'bottom' },
|
||||
{ selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' }
|
||||
];
|
||||
|
||||
const profilesTutorialSteps = [
|
||||
{ selector: '[data-card-section="profiles"]', textKey: 'tour.prof.list', position: 'bottom' },
|
||||
{ selector: '[data-cs-add="profiles"]', textKey: 'tour.prof.add', position: 'bottom' },
|
||||
{ selector: '.card[data-profile-id]', textKey: 'tour.prof.card', position: 'bottom' }
|
||||
];
|
||||
|
||||
const _fixedResolve = (step) => {
|
||||
const el = document.querySelector(step.selector);
|
||||
if (!el) return null;
|
||||
// offsetParent is null when element or any ancestor has display:none
|
||||
if (!el.offsetParent) return null;
|
||||
return el;
|
||||
};
|
||||
|
||||
const deviceTutorialSteps = [
|
||||
{ selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' },
|
||||
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
|
||||
@@ -112,6 +150,46 @@ export function startGettingStartedTutorial() {
|
||||
});
|
||||
}
|
||||
|
||||
export function startDashboardTutorial() {
|
||||
startTutorial({
|
||||
steps: dashboardTutorialSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
mode: 'fixed',
|
||||
container: null,
|
||||
resolveTarget: _fixedResolve
|
||||
});
|
||||
}
|
||||
|
||||
export function startTargetsTutorial() {
|
||||
startTutorial({
|
||||
steps: targetsTutorialSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
mode: 'fixed',
|
||||
container: null,
|
||||
resolveTarget: _fixedResolve
|
||||
});
|
||||
}
|
||||
|
||||
export function startSourcesTutorial() {
|
||||
startTutorial({
|
||||
steps: sourcesTourSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
mode: 'fixed',
|
||||
container: null,
|
||||
resolveTarget: _fixedResolve
|
||||
});
|
||||
}
|
||||
|
||||
export function startProfilesTutorial() {
|
||||
startTutorial({
|
||||
steps: profilesTutorialSteps,
|
||||
overlayId: 'getting-started-overlay',
|
||||
mode: 'fixed',
|
||||
container: null,
|
||||
resolveTarget: _fixedResolve
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTutorial() {
|
||||
if (!activeTutorial) return;
|
||||
const onClose = activeTutorial.onClose;
|
||||
|
||||
@@ -224,6 +224,24 @@
|
||||
"tour.theme": "Theme — switch between dark and light mode.",
|
||||
"tour.language": "Language — choose your preferred interface language.",
|
||||
"tour.restart": "Restart tutorial",
|
||||
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.",
|
||||
"tour.dash.running": "Running targets — live streaming metrics and quick stop control.",
|
||||
"tour.dash.stopped": "Stopped targets — ready to start with one click.",
|
||||
"tour.dash.profiles": "Profiles — active profile status and quick enable/disable toggle.",
|
||||
"tour.tgt.led_tab": "LED tab — standard LED strip targets with device and color strip configuration.",
|
||||
"tour.tgt.devices": "Devices — your WLED controllers discovered on the network.",
|
||||
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.",
|
||||
"tour.tgt.targets": "LED Targets — combine a device, color strip, and capture source for streaming.",
|
||||
"tour.tgt.kc_tab": "Key Colors — alternative target type using color-matching instead of pixel mapping.",
|
||||
"tour.src.raw": "Raw — live screen capture sources from your displays.",
|
||||
"tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).",
|
||||
"tour.src.static": "Static Image — test your setup with image files instead of live capture.",
|
||||
"tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.",
|
||||
"tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.",
|
||||
"tour.src.value": "Value — numeric data sources used as conditions in profile automation.",
|
||||
"tour.prof.list": "Profiles — automate target control based on time, audio, or value conditions.",
|
||||
"tour.prof.add": "Click + to create a new profile with targets and activation conditions.",
|
||||
"tour.prof.card": "Each card shows profile status, conditions, and quick controls to edit or toggle.",
|
||||
"calibration.tutorial.start": "Start tutorial",
|
||||
"calibration.overlay_toggle": "Overlay",
|
||||
"calibration.start_position": "Starting Position:",
|
||||
|
||||
@@ -224,6 +224,24 @@
|
||||
"tour.theme": "Тема — переключение между тёмной и светлой темой.",
|
||||
"tour.language": "Язык — выберите предпочитаемый язык интерфейса.",
|
||||
"tour.restart": "Запустить тур заново",
|
||||
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",
|
||||
"tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.",
|
||||
"tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.",
|
||||
"tour.dash.profiles": "Профили — статус активных профилей и быстрое включение/выключение.",
|
||||
"tour.tgt.led_tab": "LED — стандартные LED-цели с настройкой устройств и цветовых полос.",
|
||||
"tour.tgt.devices": "Устройства — ваши WLED-контроллеры, найденные в сети.",
|
||||
"tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.",
|
||||
"tour.tgt.targets": "LED-цели — объедините устройство, цветовую полосу и источник захвата для стриминга.",
|
||||
"tour.tgt.kc_tab": "Key Colors — альтернативный тип цели с подбором цветов вместо пиксельного маппинга.",
|
||||
"tour.src.raw": "Raw — источники захвата экрана с ваших дисплеев.",
|
||||
"tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).",
|
||||
"tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.",
|
||||
"tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.",
|
||||
"tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.",
|
||||
"tour.src.value": "Значения — числовые источники данных для условий автоматизации профилей.",
|
||||
"tour.prof.list": "Профили — автоматизируйте управление целями по времени, звуку или значениям.",
|
||||
"tour.prof.add": "Нажмите + для создания нового профиля с целями и условиями активации.",
|
||||
"tour.prof.card": "Каждая карточка показывает статус профиля, условия и кнопки управления.",
|
||||
"calibration.tutorial.start": "Начать обучение",
|
||||
"calibration.overlay_toggle": "Оверлей",
|
||||
"calibration.start_position": "Начальная Позиция:",
|
||||
|
||||
@@ -224,6 +224,24 @@
|
||||
"tour.theme": "主题 — 在深色和浅色模式之间切换。",
|
||||
"tour.language": "语言 — 选择您偏好的界面语言。",
|
||||
"tour.restart": "重新开始导览",
|
||||
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",
|
||||
"tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。",
|
||||
"tour.dash.stopped": "已停止的目标 — 一键启动。",
|
||||
"tour.dash.profiles": "配置文件 — 活动配置文件状态和快速启用/禁用切换。",
|
||||
"tour.tgt.led_tab": "LED 标签 — 标准 LED 灯带目标,包含设备和色带配置。",
|
||||
"tour.tgt.devices": "设备 — 在网络中发现的 WLED 控制器。",
|
||||
"tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。",
|
||||
"tour.tgt.targets": "LED 目标 — 将设备、色带和捕获源组合进行流式传输。",
|
||||
"tour.tgt.kc_tab": "Key Colors — 使用颜色匹配代替像素映射的替代目标类型。",
|
||||
"tour.src.raw": "原始 — 来自显示器的实时屏幕捕获源。",
|
||||
"tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。",
|
||||
"tour.src.static": "静态图片 — 使用图片文件测试您的设置。",
|
||||
"tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。",
|
||||
"tour.src.audio": "音频 — 分析麦克风或系统音频以实现响应式 LED 效果。",
|
||||
"tour.src.value": "数值 — 用于配置文件自动化条件的数字数据源。",
|
||||
"tour.prof.list": "配置文件 — 基于时间、音频或数值条件自动控制目标。",
|
||||
"tour.prof.add": "点击 + 创建包含目标和激活条件的新配置文件。",
|
||||
"tour.prof.card": "每张卡片显示配置文件状态、条件和快速编辑/切换控制。",
|
||||
"calibration.tutorial.start": "开始教程",
|
||||
"calibration.overlay_toggle": "叠加层",
|
||||
"calibration.start_position": "起始位置:",
|
||||
|
||||
Reference in New Issue
Block a user