Compare commits

...

2 Commits

Author SHA1 Message Date
2a085e63a0 Add interactive tutorial system for calibration and device cards
Some checks failed
Validate / validate (push) Failing after 8s
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 <noreply@anthropic.com>
2026-02-09 04:51:56 +03:00
fa322ee0ce Prioritize round-number ticks, add calibration tip list with i18n
Tick labels: round-number ticks (300, 900, etc.) now take priority over
edge boundary labels (288, 933). When they overlap, the boundary label
is suppressed but its tick line is preserved.

Calibration tips: convert single paragraph to bulleted list with
individual i18n keys, add tip about toggling edge inputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 03:25:34 +03:00
5 changed files with 603 additions and 26 deletions

View File

@@ -611,6 +611,7 @@ function createDeviceCard(device) {
</a> </a>
` : ''} ` : ''}
</div> </div>
<button class="card-tutorial-btn" onclick="startDeviceTutorial('${device.id}')" title="${t('device.tutorial.start')}">?</button>
</div> </div>
`; `;
} }
@@ -933,7 +934,12 @@ async function handleAddDevice(event) {
console.log('Device added successfully:', result); console.log('Device added successfully:', result);
showToast('Device added successfully', 'success'); showToast('Device added successfully', 'success');
closeAddDeviceModal(); closeAddDeviceModal();
loadDevices(); await loadDevices();
// Auto-start device tutorial on first device add
if (!localStorage.getItem('deviceTutorialSeen')) {
localStorage.setItem('deviceTutorialSeen', '1');
setTimeout(() => startDeviceTutorial(), 300);
}
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error('Failed to add device:', errorData); console.error('Failed to add device:', errorData);
@@ -1090,7 +1096,14 @@ async function showCalibration(deviceId) {
// Initialize span drag and render canvas after layout settles // Initialize span drag and render canvas after layout settles
initSpanDrag(); 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) // Re-render on container resize (e.g. window resize changes aspect-ratio container)
if (!window._calibrationResizeObserver) { if (!window._calibrationResizeObserver) {
@@ -1121,6 +1134,7 @@ function isCalibrationDirty() {
} }
function forceCloseCalibrationModal() { function forceCloseCalibrationModal() {
closeTutorial();
const deviceId = document.getElementById('calibration-device-id').value; const deviceId = document.getElementById('calibration-device-id').value;
if (deviceId) { if (deviceId) {
clearTestMode(deviceId); clearTestMode(deviceId);
@@ -1297,23 +1311,28 @@ function renderCalibrationCanvas() {
const count = seg.led_count; const count = seg.led_count;
if (count === 0) return; if (count === 0) return;
// Mandatory ticks: first and last LED index per edge, plus LED 0 if offset > 0 // Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position)
const labelsToShow = new Set(); const edgeBounds = new Set();
labelsToShow.add(0); edgeBounds.add(0);
if (count > 1) labelsToShow.add(count - 1); if (count > 1) edgeBounds.add(count - 1);
const specialTicks = new Set();
if (offset > 0 && totalLeds > 0) { if (offset > 0 && totalLeds > 0) {
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds; const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
if (zeroPos < count) labelsToShow.add(zeroPos); if (zeroPos < count) specialTicks.add(zeroPos);
} }
// Add intermediate ticks at "nice" intervals (max 5 labels per edge) // Round-number ticks get priority; edge boundary labels suppressed if overlapping
const labelsToShow = new Set([...specialTicks]);
const tickLinesOnly = new Set();
if (count > 2) { if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1); const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length; const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length;
const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22; const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22;
const maxIntermediate = Math.max(0, 5 - labelsToShow.size); const allMandatory = new Set([...edgeBounds, ...specialTicks]);
const maxIntermediate = Math.max(0, 5 - allMandatory.size);
const niceSteps = [5, 10, 25, 50, 100, 250, 500]; const niceSteps = [5, 10, 25, 50, 100, 250, 500];
let step = niceSteps[niceSteps.length - 1]; let step = niceSteps[niceSteps.length - 1];
for (const s of niceSteps) { for (const s of niceSteps) {
@@ -1323,18 +1342,17 @@ function renderCalibrationCanvas() {
} }
} }
// Pixel position helper (0..edgeLen along the edge)
const tickPx = i => { const tickPx = i => {
const f = i / (count - 1); const f = i / (count - 1);
return (seg.reverse ? (1 - f) : f) * edgeLen; return (seg.reverse ? (1 - f) : f) * edgeLen;
}; };
// Collect pixel positions of mandatory ticks // Phase 1: place round-number ticks (checked against specials + each other)
const placed = []; const placed = [];
labelsToShow.forEach(i => placed.push(tickPx(i))); specialTicks.forEach(i => placed.push(tickPx(i)));
// Add ticks at LED indices divisible by step
for (let i = 1; i < count - 1; i++) { for (let i = 1; i < count - 1; i++) {
if (specialTicks.has(i)) continue;
const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
if (idx % step === 0) { if (idx % step === 0) {
const px = tickPx(i); const px = tickPx(i);
@@ -1344,9 +1362,23 @@ function renderCalibrationCanvas() {
} }
} }
} }
// Phase 2: edge boundaries — show label unless overlapping a round-number tick
edgeBounds.forEach(bi => {
if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
const px = tickPx(bi);
if (placed.some(p => Math.abs(px - p) < minSpacing)) {
tickLinesOnly.add(bi);
} else {
labelsToShow.add(bi);
placed.push(px);
}
});
} else {
edgeBounds.forEach(i => labelsToShow.add(i));
} }
// Tick styling — min/max ticks extend to container border, others short // Tick styling
const tickLenLong = toggleSize + 3; const tickLenLong = toggleSize + 3;
const tickLenShort = 4; const tickLenShort = 4;
ctx.strokeStyle = tickStroke; ctx.strokeStyle = tickStroke;
@@ -1354,45 +1386,66 @@ function renderCalibrationCanvas() {
ctx.fillStyle = tickFill; ctx.fillStyle = tickFill;
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'; ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
// Draw labeled ticks
labelsToShow.forEach(i => { labelsToShow.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5; const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction; const displayFraction = seg.reverse ? (1 - fraction) : fraction;
const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i;
const tickLen = (i === 0 || i === count - 1) ? tickLenLong : tickLenShort; const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
if (geo.horizontal) { if (geo.horizontal) {
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1); const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
const axisY = axisPos[seg.edge]; const axisY = axisPos[seg.edge];
const tickDir = seg.edge === 'top' ? 1 : -1; // tick toward container const tickDir = seg.edge === 'top' ? 1 : -1;
// Tick line
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(tx, axisY); ctx.moveTo(tx, axisY);
ctx.lineTo(tx, axisY + tickDir * tickLen); ctx.lineTo(tx, axisY + tickDir * tickLen);
ctx.stroke(); ctx.stroke();
// Label outside
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top'; ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1); ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1);
} else { } else {
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1); const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
const axisX = axisPos[seg.edge]; const axisX = axisPos[seg.edge];
const tickDir = seg.edge === 'left' ? 1 : -1; // tick toward container const tickDir = seg.edge === 'left' ? 1 : -1;
// Tick line
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(axisX, ty); ctx.moveTo(axisX, ty);
ctx.lineTo(axisX + tickDir * tickLen, ty); ctx.lineTo(axisX + tickDir * tickLen, ty);
ctx.stroke(); ctx.stroke();
// Label outside
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.textAlign = seg.edge === 'left' ? 'right' : 'left'; ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty); ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
} }
}); });
// Draw tick lines only (no labels) for suppressed edge boundaries
tickLinesOnly.forEach(i => {
const fraction = count > 1 ? i / (count - 1) : 0.5;
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
if (geo.horizontal) {
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
const axisY = axisPos[seg.edge];
const tickDir = seg.edge === 'top' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(tx, axisY);
ctx.lineTo(tx, axisY + tickDir * tickLenLong);
ctx.stroke();
} else {
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
const axisX = axisPos[seg.edge];
const tickDir = seg.edge === 'left' ? 1 : -1;
ctx.beginPath();
ctx.moveTo(axisX, ty);
ctx.lineTo(axisX + tickDir * tickLenLong, ty);
ctx.stroke();
}
});
// Draw direction chevron at full-edge midpoint (not affected by span) // Draw direction chevron at full-edge midpoint (not affected by span)
const s = 7; const s = 7;
let mx, my, angle; let mx, my, angle;
@@ -1789,6 +1842,7 @@ document.addEventListener('mousedown', (e) => {
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return; if (!e.target.classList.contains('modal')) return;
if (backdropMouseDownTarget !== e.target) return; if (backdropMouseDownTarget !== e.target) return;
if (activeTutorial) return;
const modalId = e.target.id; const modalId = e.target.id;
@@ -1832,3 +1886,229 @@ window.addEventListener('beforeunload', () => {
clearInterval(refreshInterval); 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(); }
}

View File

@@ -80,11 +80,11 @@
<div class="modal-content" style="max-width: 700px;"> <div class="modal-content" style="max-width: 700px;">
<div class="modal-header"> <div class="modal-header">
<h2 data-i18n="calibration.title">📐 LED Calibration</h2> <h2 data-i18n="calibration.title">📐 LED Calibration</h2>
<button class="tutorial-trigger-btn" onclick="startCalibrationTutorial()" data-i18n-title="calibration.tutorial.start" title="Start tutorial">?</button>
<button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close">&#x2715;</button> <button class="modal-close-btn" onclick="closeCalibrationModal()" title="Close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="calibration-device-id"> <input type="hidden" id="calibration-device-id">
<p class="section-tip" data-i18n="calibration.preview.click_hint">Click an edge to toggle test LEDs on/off</p>
<!-- Interactive Preview with integrated LED inputs and test toggles --> <!-- Interactive Preview with integrated LED inputs and test toggles -->
<div style="margin-bottom: 12px; padding: 0 24px;"> <div style="margin-bottom: 12px; padding: 0 24px;">
<div class="calibration-preview"> <div class="calibration-preview">
@@ -168,6 +168,23 @@
</div> </div>
<!-- Tutorial Overlay -->
<div id="tutorial-overlay" class="tutorial-overlay">
<div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div>
<div class="tutorial-tooltip">
<div class="tutorial-tooltip-header">
<span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()">&times;</button>
</div>
<p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()">&#8592;</button>
<button class="tutorial-next-btn" onclick="tutorialNext()">&#8594;</button>
</div>
</div>
</div>
<div id="calibration-error" class="error-message" style="display: none;"></div> <div id="calibration-error" class="error-message" style="display: none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -304,6 +321,23 @@
</div> </div>
<!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
<div class="tutorial-backdrop"></div>
<div class="tutorial-ring"></div>
<div class="tutorial-tooltip">
<div class="tutorial-tooltip-header">
<span class="tutorial-step-counter"></span>
<button class="tutorial-close-btn" onclick="closeTutorial()">&times;</button>
</div>
<p class="tutorial-tooltip-text"></p>
<div class="tutorial-tooltip-nav">
<button class="tutorial-prev-btn" onclick="tutorialPrev()">&#8592;</button>
<button class="tutorial-next-btn" onclick="tutorialNext()">&#8594;</button>
</div>
</div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script> <script>
// Initialize theme // Initialize theme

View File

@@ -77,6 +77,14 @@
"device.health.online": "WLED Online", "device.health.online": "WLED Online",
"device.health.offline": "WLED Offline", "device.health.offline": "WLED Offline",
"device.health.checking": "Checking...", "device.health.checking": "Checking...",
"device.tutorial.start": "Start tutorial",
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from WLED",
"device.tip.brightness": "Slide to adjust device brightness",
"device.tip.start": "Start or stop screen capture processing",
"device.tip.settings": "Configure device settings (display, FPS, health check)",
"device.tip.calibrate": "Calibrate LED positions, direction, and coverage",
"device.tip.webui": "Open WLED's built-in web interface for advanced configuration",
"device.tip.add": "Click here to add a new WLED device",
"settings.title": "Device Settings", "settings.title": "Device Settings",
"settings.brightness": "Brightness:", "settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)", "settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
@@ -90,7 +98,14 @@
"settings.saved": "Settings saved successfully", "settings.saved": "Settings saved successfully",
"settings.failed": "Failed to save settings", "settings.failed": "Failed to save settings",
"calibration.title": "LED Calibration", "calibration.title": "LED Calibration",
"calibration.preview.click_hint": "Click an edge to toggle test LEDs on/off", "calibration.tip.led_count": "Enter LED count per edge",
"calibration.tip.start_corner": "Click a corner to set the start position",
"calibration.tip.direction": "Toggle LED strip direction (clockwise / counterclockwise)",
"calibration.tip.offset": "Set LED offset — distance from LED 0 to the start corner",
"calibration.tip.span": "Drag green bars to adjust coverage span",
"calibration.tip.test": "Click an edge to toggle test LEDs",
"calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs",
"calibration.tutorial.start": "Start tutorial",
"calibration.start_position": "Starting Position:", "calibration.start_position": "Starting Position:",
"calibration.position.bottom_left": "Bottom Left", "calibration.position.bottom_left": "Bottom Left",
"calibration.position.bottom_right": "Bottom Right", "calibration.position.bottom_right": "Bottom Right",

View File

@@ -77,6 +77,14 @@
"device.health.online": "WLED Онлайн", "device.health.online": "WLED Онлайн",
"device.health.offline": "WLED Недоступен", "device.health.offline": "WLED Недоступен",
"device.health.checking": "Проверка...", "device.health.checking": "Проверка...",
"device.tutorial.start": "Начать обучение",
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически из WLED",
"device.tip.brightness": "Перетащите для регулировки яркости",
"device.tip.start": "Запуск или остановка захвата экрана",
"device.tip.settings": "Настройки устройства (дисплей, FPS, интервал проверки)",
"device.tip.calibrate": "Калибровка позиций LED, направления и зоны покрытия",
"device.tip.webui": "Открыть встроенный веб-интерфейс WLED для расширенной настройки",
"device.tip.add": "Нажмите, чтобы добавить новое WLED устройство",
"settings.title": "Настройки Устройства", "settings.title": "Настройки Устройства",
"settings.brightness": "Яркость:", "settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)", "settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
@@ -90,7 +98,14 @@
"settings.saved": "Настройки успешно сохранены", "settings.saved": "Настройки успешно сохранены",
"settings.failed": "Не удалось сохранить настройки", "settings.failed": "Не удалось сохранить настройки",
"calibration.title": "Калибровка Светодиодов", "calibration.title": "Калибровка Светодиодов",
"calibration.preview.click_hint": "Нажмите на край чтобы включить/выключить тест светодиодов", "calibration.tip.led_count": "Укажите количество LED на каждой стороне",
"calibration.tip.start_corner": "Нажмите на угол для выбора стартовой позиции",
"calibration.tip.direction": "Переключение направления ленты (по часовой / против часовой)",
"calibration.tip.offset": "Смещение LED — расстояние от LED 0 до стартового угла",
"calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия",
"calibration.tip.test": "Нажмите на край для теста LED",
"calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей",
"calibration.tutorial.start": "Начать обучение",
"calibration.start_position": "Начальная Позиция:", "calibration.start_position": "Начальная Позиция:",
"calibration.position.bottom_left": "Нижний Левый", "calibration.position.bottom_left": "Нижний Левый",
"calibration.position.bottom_right": "Нижний Правый", "calibration.position.bottom_right": "Нижний Правый",

View File

@@ -206,7 +206,7 @@ section {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 12px 20px 20px;
position: relative; position: relative;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
@@ -215,6 +215,31 @@ section {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.card-tutorial-btn {
position: absolute;
bottom: 10px;
right: 10px;
background: none;
border: 1.5px solid var(--border-color);
color: var(--text-muted, #777);
font-size: 0.7rem;
font-weight: bold;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
transition: color 0.2s, background 0.2s, border-color 0.2s;
padding: 0;
}
.card-tutorial-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.card-remove-btn { .card-remove-btn {
position: absolute; position: absolute;
top: 10px; top: 10px;
@@ -242,7 +267,7 @@ section {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 4px; margin-bottom: 10px;
padding-right: 30px; padding-right: 30px;
} }
@@ -629,6 +654,15 @@ section {
text-decoration: underline; text-decoration: underline;
} }
ul.section-tip {
list-style: disc;
padding-left: 28px;
}
ul.section-tip li {
margin: 2px 0;
}
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 15px;
} }
@@ -1359,3 +1393,202 @@ input:-webkit-autofill:focus {
} }
} }
/* Tutorial System */
.tutorial-trigger-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--primary-color);
background: transparent;
color: var(--primary-color);
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
margin-right: 8px;
flex-shrink: 0;
}
.tutorial-trigger-btn:hover {
background: var(--primary-color);
color: white;
}
#calibration-modal .modal-body {
position: relative;
}
.tutorial-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
pointer-events: none;
}
.tutorial-overlay.active {
display: block;
pointer-events: auto;
}
.tutorial-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
transition: clip-path 0.3s ease;
}
.tutorial-ring {
position: absolute;
border: 2px solid var(--primary-color);
border-radius: 6px;
pointer-events: none;
transition: all 0.3s ease;
animation: tutorial-pulse 2s infinite;
}
@keyframes tutorial-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.6); }
50% { box-shadow: 0 0 0 6px rgba(76, 175, 80, 0); }
}
.tutorial-tooltip {
position: absolute;
width: 260px;
background: var(--card-bg);
border: 2px solid var(--primary-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 102;
pointer-events: auto;
animation: tutorial-tooltip-in 0.25s ease-out;
}
@keyframes tutorial-tooltip-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.tutorial-tooltip-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
}
.tutorial-step-counter {
font-size: 0.8rem;
font-weight: 600;
color: var(--primary-color);
}
.tutorial-close-btn {
background: none;
border: none;
color: #777;
font-size: 1.3rem;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
padding: 0;
line-height: 1;
}
.tutorial-close-btn:hover {
color: var(--text-color);
background: rgba(128, 128, 128, 0.15);
}
.tutorial-tooltip-text {
margin: 0;
padding: 12px;
line-height: 1.5;
color: var(--text-color);
font-size: 0.9rem;
}
.tutorial-tooltip-nav {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--border-color);
}
.tutorial-prev-btn,
.tutorial-next-btn {
flex: 1;
padding: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: opacity 0.2s;
}
.tutorial-prev-btn {
background: var(--border-color);
color: var(--text-color);
}
.tutorial-next-btn {
background: var(--primary-color);
color: white;
}
.tutorial-prev-btn:hover:not(:disabled),
.tutorial-next-btn:hover:not(:disabled) {
opacity: 0.85;
}
.tutorial-prev-btn:disabled,
.tutorial-next-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.tutorial-target {
position: relative;
z-index: 101 !important;
}
/* Fixed (viewport-level) tutorial overlay for device cards */
.tutorial-overlay-fixed {
position: fixed;
z-index: 10000;
}
.tutorial-overlay-fixed .tutorial-backdrop {
position: fixed;
}
.tutorial-overlay-fixed .tutorial-ring {
position: fixed;
}
.tutorial-overlay-fixed .tutorial-tooltip {
position: absolute;
z-index: 10002;
animation: none;
opacity: 1;
}
/* target z-index for fixed overlay is set inline via JS (target is outside overlay DOM) */