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:
2026-02-27 14:15:41 +03:00
parent 111bfe743a
commit efb6cf7ce6
11 changed files with 166 additions and 7 deletions

View File

@@ -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,

View File

@@ -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>`;

View File

@@ -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

View File

@@ -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';

View File

@@ -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 || (() => '');

View File

@@ -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;