From efb6cf7ce698a84d2d721ae2c8633facc2aa3f80 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 27 Feb 2026 14:15:41 +0300 Subject: [PATCH] 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 --- CLAUDE.md | 9 +++ .../src/wled_controller/static/css/cards.css | 2 +- server/src/wled_controller/static/js/app.js | 8 ++ .../static/js/features/dashboard.js | 7 +- .../static/js/features/profiles.js | 11 ++- .../static/js/features/streams.js | 2 +- .../static/js/features/targets.js | 2 +- .../static/js/features/tutorials.js | 78 +++++++++++++++++++ .../wled_controller/static/locales/en.json | 18 +++++ .../wled_controller/static/locales/ru.json | 18 +++++ .../wled_controller/static/locales/zh.json | 18 +++++ 11 files changed, 166 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a94b42..bfc4958 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 624fc2b..b58058e 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -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 { diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 2cbde51..ec39a2e 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index b617f63..f8fdfef 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -274,7 +274,7 @@ function _updateProfilesInPlace(profiles) { function _renderPollIntervalSelect() { const sec = Math.round(dashboardPollInterval / 1000); - return `${sec}s`; + return `${sec}s`; } 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 = `
${pollSelect}
`; if (isFirstLoad) { - container.innerHTML = `
- ${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)} + container.innerHTML = `${toolbar}
+ ${_sectionHeader('perf', t('dashboard.section.performance'), '')} ${_sectionContent('perf', renderPerfSection())}
${dynamicHtml}
`; diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index a405b77..9f16b58 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -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 = `
`; + container.innerHTML = toolbar + csProfiles.render(items); csProfiles.bind(); // Localize data-i18n elements within the profiles container only diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index d295bef..341b2e2 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1301,7 +1301,7 @@ function renderPictureSourcesList(streams) { const tabBar = `
${tabs.map(tab => `` - ).join('')}
`; + ).join('')}
`; const renderAudioSourceCard = (src) => { const isMono = src.source_type === 'mono'; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index d33e3e5..1d38dd0 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -576,7 +576,7 @@ export async function loadTargetsTab() { const tabBar = `
${subTabs.map(tab => `` - ).join('')}
`; + ).join('')}`; // Use window.createPatternTemplateCard to avoid circular import const createPatternTemplateCard = window.createPatternTemplateCard || (() => ''); diff --git a/server/src/wled_controller/static/js/features/tutorials.js b/server/src/wled_controller/static/js/features/tutorials.js index 8c19c3d..e7ab459 100644 --- a/server/src/wled_controller/static/js/features/tutorials.js +++ b/server/src/wled_controller/static/js/features/tutorials.js @@ -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; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 9e8addf..9c65afc 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index d050133..d4232c6 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Начальная Позиция:", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 6f6ce4d..50caaa8 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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": "起始位置:",