From 2a085e63a099c51b75a92c143f7e9f795e21b406 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 9 Feb 2026 04:51:56 +0300 Subject: [PATCH] Add interactive tutorial system for calibration and device cards Generic tutorial engine supports absolute (modal) and fixed (viewport) positioning modes with spotlight backdrop, pulsing ring, and tooltip. Calibration tutorial covers LED count, corner, direction, offset, span, test, and toggle inputs. Device tutorial walks through card controls. Auto-triggers on first calibration open and first device add. Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/static/app.js | 245 +++++++++++++++++- server/src/wled_controller/static/index.html | 42 ++- .../wled_controller/static/locales/en.json | 11 + .../wled_controller/static/locales/ru.json | 11 + server/src/wled_controller/static/style.css | 228 +++++++++++++++- 5 files changed, 526 insertions(+), 11 deletions(-) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 13722fb..d8d3180 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -611,6 +611,7 @@ function createDeviceCard(device) { ` : ''} + `; } @@ -933,7 +934,12 @@ async function handleAddDevice(event) { console.log('Device added successfully:', result); showToast('Device added successfully', 'success'); closeAddDeviceModal(); - loadDevices(); + await loadDevices(); + // Auto-start device tutorial on first device add + if (!localStorage.getItem('deviceTutorialSeen')) { + localStorage.setItem('deviceTutorialSeen', '1'); + setTimeout(() => startDeviceTutorial(), 300); + } } else { const errorData = await response.json(); console.error('Failed to add device:', errorData); @@ -1090,7 +1096,14 @@ async function showCalibration(deviceId) { // Initialize span drag and render canvas after layout settles initSpanDrag(); - requestAnimationFrame(() => renderCalibrationCanvas()); + requestAnimationFrame(() => { + renderCalibrationCanvas(); + // Auto-start tutorial on first open + if (!localStorage.getItem('calibrationTutorialSeen')) { + localStorage.setItem('calibrationTutorialSeen', '1'); + startCalibrationTutorial(); + } + }); // Re-render on container resize (e.g. window resize changes aspect-ratio container) if (!window._calibrationResizeObserver) { @@ -1121,6 +1134,7 @@ function isCalibrationDirty() { } function forceCloseCalibrationModal() { + closeTutorial(); const deviceId = document.getElementById('calibration-device-id').value; if (deviceId) { clearTestMode(deviceId); @@ -1828,6 +1842,7 @@ document.addEventListener('mousedown', (e) => { document.addEventListener('click', (e) => { if (!e.target.classList.contains('modal')) return; if (backdropMouseDownTarget !== e.target) return; + if (activeTutorial) return; const modalId = e.target.id; @@ -1871,3 +1886,229 @@ window.addEventListener('beforeunload', () => { clearInterval(refreshInterval); } }); + +// ============================================================================= +// Tutorial System (generic engine) +// ============================================================================= + +let activeTutorial = null; +// Shape: { steps, overlay, mode, step, resolveTarget, container } +// mode: 'absolute' (within a container) or 'fixed' (viewport-level) + +const calibrationTutorialSteps = [ + { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, + { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, + { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, + { selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' }, + { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, + { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, + { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'bottom' } +]; + +const deviceTutorialSteps = [ + { selector: '.card-subtitle', textKey: 'device.tip.metadata', position: 'bottom' }, + { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, + { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, + { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, + { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.calibrate', position: 'top' }, + { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', position: 'top' } +]; + +function startTutorial(config) { + closeTutorial(); + const overlay = document.getElementById(config.overlayId); + if (!overlay) return; + + activeTutorial = { + steps: config.steps, + overlay: overlay, + mode: config.mode, + step: 0, + resolveTarget: config.resolveTarget, + container: config.container + }; + + overlay.classList.add('active'); + document.addEventListener('keydown', handleTutorialKey); + showTutorialStep(0); +} + +function startCalibrationTutorial() { + const container = document.querySelector('#calibration-modal .modal-body'); + if (!container) return; + startTutorial({ + steps: calibrationTutorialSteps, + overlayId: 'tutorial-overlay', + mode: 'absolute', + container: container, + resolveTarget: (step) => document.querySelector(step.selector) + }); +} + +function startDeviceTutorial(deviceId) { + // Resolve the device ID to target (don't capture card reference — it goes stale when loadDevices rebuilds DOM) + const selector = deviceId + ? `.card[data-device-id="${deviceId}"]` + : '.card[data-device-id]'; + if (!document.querySelector(selector)) return; + startTutorial({ + steps: deviceTutorialSteps, + overlayId: 'device-tutorial-overlay', + mode: 'fixed', + container: null, + resolveTarget: (step) => { + const card = document.querySelector(selector); + if (!card) return null; + return step.global + ? document.querySelector(step.selector) + : card.querySelector(step.selector); + } + }); +} + +function closeTutorial() { + if (!activeTutorial) return; + activeTutorial.overlay.classList.remove('active'); + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + document.removeEventListener('keydown', handleTutorialKey); + activeTutorial = null; +} + +function tutorialNext() { + if (!activeTutorial) return; + if (activeTutorial.step < activeTutorial.steps.length - 1) { + showTutorialStep(activeTutorial.step + 1); + } else { + closeTutorial(); + } +} + +function tutorialPrev() { + if (!activeTutorial) return; + if (activeTutorial.step > 0) { + showTutorialStep(activeTutorial.step - 1); + } +} + +function showTutorialStep(index) { + if (!activeTutorial) return; + activeTutorial.step = index; + const step = activeTutorial.steps[index]; + const overlay = activeTutorial.overlay; + const isFixed = activeTutorial.mode === 'fixed'; + + // Remove previous target highlight + document.querySelectorAll('.tutorial-target').forEach(el => { + el.classList.remove('tutorial-target'); + el.style.zIndex = ''; + }); + + // Find and highlight target + const target = activeTutorial.resolveTarget(step); + if (!target) return; + target.classList.add('tutorial-target'); + // For fixed overlays, target must be above the z-index:10000 overlay + if (isFixed) target.style.zIndex = '10001'; + + const targetRect = target.getBoundingClientRect(); + const pad = 6; + let x, y, w, h; + + if (isFixed) { + // Fixed mode: coordinates are viewport-relative + x = targetRect.left - pad; + y = targetRect.top - pad; + w = targetRect.width + pad * 2; + h = targetRect.height + pad * 2; + } else { + // Absolute mode: coordinates relative to container + const containerRect = activeTutorial.container.getBoundingClientRect(); + x = targetRect.left - containerRect.left - pad; + y = targetRect.top - containerRect.top - pad; + w = targetRect.width + pad * 2; + h = targetRect.height + pad * 2; + } + + // Update backdrop clip-path (polygon with rectangular cutout) + const backdrop = overlay.querySelector('.tutorial-backdrop'); + if (backdrop) { + backdrop.style.clipPath = `polygon( + 0% 0%, 0% 100%, + ${x}px 100%, ${x}px ${y}px, + ${x + w}px ${y}px, ${x + w}px ${y + h}px, + ${x}px ${y + h}px, ${x}px 100%, + 100% 100%, 100% 0%)`; + } + + // Position ring around target + const ring = overlay.querySelector('.tutorial-ring'); + if (ring) { + ring.style.left = x + 'px'; + ring.style.top = y + 'px'; + ring.style.width = w + 'px'; + ring.style.height = h + 'px'; + } + + // Update tooltip content + const tooltip = overlay.querySelector('.tutorial-tooltip'); + const textEl = overlay.querySelector('.tutorial-tooltip-text'); + const counterEl = overlay.querySelector('.tutorial-step-counter'); + if (textEl) textEl.textContent = t(step.textKey); + if (counterEl) counterEl.textContent = `${index + 1} / ${activeTutorial.steps.length}`; + + // Enable/disable nav buttons + const prevBtn = overlay.querySelector('.tutorial-prev-btn'); + const nextBtn = overlay.querySelector('.tutorial-next-btn'); + if (prevBtn) prevBtn.disabled = (index === 0); + if (nextBtn) nextBtn.textContent = (index === activeTutorial.steps.length - 1) ? '\u2713' : '\u2192'; + + // Position tooltip + if (tooltip) { + positionTutorialTooltip(tooltip, x, y, w, h, step.position, isFixed); + } +} + +function positionTutorialTooltip(tooltip, sx, sy, sw, sh, preferred, isFixed) { + const gap = 12; + const tooltipW = 260; + // Place offscreen to measure real height without visual flash + tooltip.setAttribute('style', 'left:-9999px;top:-9999px'); + const tooltipH = tooltip.offsetHeight || 150; + + const positions = { + top: { x: sx + sw / 2 - tooltipW / 2, y: sy - tooltipH - gap }, + bottom: { x: sx + sw / 2 - tooltipW / 2, y: sy + sh + gap }, + left: { x: sx - tooltipW - gap, y: sy + sh / 2 - tooltipH / 2 }, + right: { x: sx + sw + gap, y: sy + sh / 2 - tooltipH / 2 } + }; + + let pos = positions[preferred] || positions.bottom; + + const cW = isFixed ? window.innerWidth : activeTutorial.container.clientWidth; + const cH = isFixed ? window.innerHeight : activeTutorial.container.clientHeight; + + // If preferred position overflows, try opposite + if (pos.y + tooltipH > cH || pos.y < 0 || pos.x + tooltipW > cW || pos.x < 0) { + const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; + const alt = positions[opposite[preferred]]; + if (alt && alt.y >= 0 && alt.y + tooltipH <= cH && alt.x >= 0 && alt.x + tooltipW <= cW) { + pos = alt; + } + } + + pos.x = Math.max(8, Math.min(cW - tooltipW - 8, pos.x)); + pos.y = Math.max(8, Math.min(cH - tooltipH - 8, pos.y)); + + // Force-set all positioning via setAttribute to avoid any style-setting quirks + tooltip.setAttribute('style', `left:${Math.round(pos.x)}px;top:${Math.round(pos.y)}px`); +} + +function handleTutorialKey(e) { + if (!activeTutorial) return; + if (e.key === 'Escape') { closeTutorial(); e.stopPropagation(); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); } +} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 8b2a919..ac06a11 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -80,17 +80,11 @@