feat(onboarding): guided first-run setup wizard (phase 4, final)

A multi-step first-run wizard that takes a brand-new user from install to a
running, calibrated ambient light in ~2 minutes, orchestrating the existing
primitives (no node graph required).

- features/setup-wizard.ts + modals/setup-wizard.html: welcome -> find device
  (discovery list + manual add via the canonical, URL-validated POST /devices)
  -> pick screen (GET /config/displays) -> scaffold (POST /setup/scaffold) ->
  calibrate (embeds the phase-3 auto-calibration flow via mountAutoCalibration
  on the scaffolded CSS + device) -> start -> done, with a progress indicator.
- First-run trigger in app.ts (checkAndOpenWizardIfNeeded): on load, if the
  onboarding flag is unset AND no output targets exist, the wizard takes over
  and the tooltip tour is suppressed; on finish/skip it PUTs the onboarding
  flag and sets localStorage tour_completed so neither re-fires. Re-runnable.
- tutorials.ts exposes TOUR_KEY + a takeover hook so the getting-started tour
  and the wizard never double-fire.
- Calibrate step always calls unmountAutoCalibration() on exit so the device
  is restored. i18n in en/ru/zh (wizard.* keys + common.back).

Final phase of the edge-calibration + first-run-wizard feature. Big Bang final
gate green: tsc --noEmit clean, npm run build passes, full pytest suite
2064 passed / 2 skipped, ruff clean.
This commit is contained in:
2026-06-08 16:27:55 +03:00
parent abc204c04e
commit 81b18089e1
10 changed files with 1462 additions and 6 deletions
@@ -2651,3 +2651,371 @@ textarea:focus-visible {
.autocal-direction-btn { transition: none; }
}
/* ==========================================================
Setup Wizard (features/setup-wizard.ts)
========================================================= */
/* Progress bar */
.wizard-progress-bar {
margin-bottom: 6px;
}
.wizard-progress-track {
height: 3px;
background: var(--border-color);
border-radius: 2px;
overflow: hidden;
}
.wizard-progress-fill {
height: 100%;
background: var(--primary-color);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Pip indicators */
.wizard-progress-labels {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.wizard-pip {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
color: var(--text-muted, var(--secondary-text-color));
border: 1.5px solid var(--border-color);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.wizard-pip--done {
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
border-color: var(--primary-color);
}
.wizard-pip--done .icon { width: 12px; height: 12px; }
.wizard-pip--active {
background: var(--primary-color);
color: #fff;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 25%, transparent);
}
/* Step layout */
.wizard-step {
display: flex;
flex-direction: column;
gap: 18px;
}
.wizard-step-header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.wizard-step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
.wizard-step-icon .icon { width: 18px; height: 18px; }
.wizard-step-icon--ok {
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
}
.wizard-step-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 4px;
}
.wizard-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
line-height: 1.5;
margin: 0;
}
/* Welcome step */
.wizard-step--welcome {
align-items: center;
text-align: center;
padding: 8px 0;
}
.wizard-welcome-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
margin-bottom: 4px;
}
.wizard-welcome-icon .icon { width: 32px; height: 32px; }
.wizard-welcome-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
width: 100%;
max-width: 360px;
}
.wizard-welcome-list li {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.88rem;
color: var(--text-color);
padding: 8px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm, 6px);
}
.wizard-welcome-list li .icon { width: 16px; height: 16px; color: var(--primary-color); flex-shrink: 0; }
/* Discovery section */
.wizard-discovery-section { display: flex; flex-direction: column; gap: 8px; }
.wizard-section-label {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, var(--secondary-text-color));
padding-bottom: 4px;
}
.wizard-section-label--scan {
display: flex;
align-items: center;
justify-content: space-between;
}
.wizard-scan-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
font-weight: 600;
color: var(--primary-color);
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm, 4px);
transition: background 0.15s;
}
.wizard-scan-btn:hover { background: color-mix(in srgb, var(--primary-color) 10%, transparent); }
.wizard-scan-btn .icon { width: 12px; height: 12px; }
.wizard-discovery-scanning {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-discovery-empty {
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-discovery-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.wizard-discovery-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--card-bg);
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-discovery-item:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
.wizard-discovery-icon { color: var(--primary-color); flex-shrink: 0; }
.wizard-discovery-icon .icon { width: 20px; height: 20px; }
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-badge {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 10px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
/* Display list */
.wizard-display-list { display: flex; flex-direction: column; gap: 6px; }
.wizard-display-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--card-bg);
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-display-item:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
.wizard-display-item--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
}
.wizard-display-icon { color: var(--primary-color); flex-shrink: 0; }
.wizard-display-icon .icon { width: 20px; height: 20px; }
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); }
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); }
.wizard-display-check { color: var(--primary-color); }
.wizard-display-check .icon { width: 16px; height: 16px; }
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
/* Scaffold / start progress */
.wizard-scaffold-progress {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); }
/* Calibrate container */
.wizard-calibrate-container {
min-height: 80px;
}
/* Done step */
.wizard-step--done {
align-items: center;
text-align: center;
padding: 8px 0;
}
.wizard-done-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
margin-bottom: 4px;
}
.wizard-done-icon .icon { width: 32px; height: 32px; }
.wizard-done-summary {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 360px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
padding: 12px 16px;
}
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); }
.wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
/* Wizard form rows */
.wizard-form-row { display: flex; flex-direction: column; gap: 6px; }
.wizard-form-label { font-size: 0.82rem; font-weight: 600; color: var(--text-color); }
/* Error banner */
.wizard-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--danger-color, #f44336) 30%, transparent);
color: var(--danger-color, #f44336);
font-size: 0.82rem;
line-height: 1.4;
}
.wizard-error .icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* Footer (nav buttons) */
.wizard-footer {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
.wizard-footer > .btn:first-child { margin-right: auto; }
.wizard-footer--done { justify-content: center; border-top: none; padding-top: 0; }
.wizard-footer--done > .btn:first-child { margin-right: 0; }
/* Btn spinner (inline in disabled state) */
.btn-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Toolbar wizard re-run button */
.header-btn[id="wizard-rerun-btn"] { }
@media (prefers-reduced-motion: reduce) {
.wizard-progress-fill,
.wizard-pip,
.wizard-discovery-item,
.wizard-display-item { transition: none; }
.btn-spinner { animation: none; }
}
+35 -2
View File
@@ -36,7 +36,16 @@ import {
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
startIntegrationsTutorial,
closeTutorial, tutorialNext, tutorialPrev,
TOUR_KEY,
} from './features/tutorials.ts';
import {
openSetupWizard, closeSetupWizard,
checkAndOpenWizardIfNeeded,
wizardNext, wizardBack, wizardSkip, wizardFinish,
wizardShowManual, wizardHideManual, wizardRescan,
wizardSelectDiscovered, wizardAddManualDevice, wizardUseExistingDevice,
wizardSelectDisplay,
} from './features/setup-wizard.ts';
// Layer 4: devices, dashboard, streams, pattern-templates, automations
import {
@@ -329,6 +338,21 @@ Object.assign(window, {
selectDisplay,
formatDisplayLabel,
// setup wizard
openSetupWizard,
closeSetupWizard,
wizardNext,
wizardBack,
wizardSkip,
wizardFinish,
wizardShowManual,
wizardHideManual,
wizardRescan,
wizardSelectDiscovered,
wizardAddManualDevice,
wizardUseExistingDevice,
wizardSelectDisplay,
// tutorials
startCalibrationTutorial,
startDeviceTutorial,
@@ -951,8 +975,17 @@ document.addEventListener('DOMContentLoaded', async () => {
setProjectUrls(serverRepoUrl, serverDonateUrl);
initDonationBanner();
// Show getting-started tutorial on first visit
if (!localStorage.getItem('tour_completed')) {
// First-run: wizard wins over the tooltip tour.
//
// Precedence (explicit):
// 1. If backend says onboarded=false AND no output targets exist
// → open the setup wizard (suppresses tooltip tour — wizard owns
// the first-run experience; it sets localStorage TOUR_KEY on
// completion/skip so the tour never double-fires on reload).
// 2. Otherwise (already onboarded, or has targets but no wizard flag)
// → fall back to the existing tooltip tour logic unchanged.
const wizardOpened = await checkAndOpenWizardIfNeeded();
if (!wizardOpened && !localStorage.getItem(TOUR_KEY)) {
setTimeout(() => startGettingStartedTutorial(), 600);
}
} catch (err) {
@@ -0,0 +1,811 @@
/**
* Setup Wizard — multi-step first-run flow.
*
* Guides a brand-new user from zero to a running, calibrated LED strip in
* roughly seven steps:
* 1. Welcome
* 2. Find device — discovery scan + manual add fallback
* 3. Pick screen — GET /api/v1/config/displays
* 4. Scaffold — POST /api/v1/setup/scaffold → entity ids
* 5. Calibrate — embed mountAutoCalibration (Phase 3 component)
* 6. Start output — POST /api/v1/output-targets/{id}/start
* 7. Done
*
* First-run precedence (explicit):
* - app.ts checks GET /preferences/onboarding
* - if onboarded=false AND no output targets → open wizard, suppress tour
* - wizard completion/skip → PUT /preferences/onboarding {onboarded:true}
* + localStorage 'tour_completed' = '1' so the tour never double-fires
* - if onboarded=true → existing tour logic runs unchanged
*
* Re-entrant: openSetupWizard() is exported so a toolbar button can reopen it.
*/
import { apiGet, apiPost, apiPut } from '../core/api-client.ts';
import { devicesCache, outputTargetsCache, displaysCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { mountAutoCalibration, unmountAutoCalibration } from './auto-calibration.ts';
import {
ICON_MONITOR, ICON_SPARKLES, ICON_DEVICE, ICON_OK, ICON_CHECK, ICON_ROCKET_ICON,
ICON_CALIBRATION, ICON_START, ICON_SEARCH, ICON_PLUS,
} from '../core/icons.ts';
import { getDeviceTypeIcon } from '../core/icons.ts';
import type { Device } from '../types.ts';
import type { Display } from '../types.ts';
// ── Types ──────────────────────────────────────────────────────────────────────
type WizardStep = 'welcome' | 'device' | 'display' | 'scaffold' | 'calibrate' | 'start' | 'done';
interface DiscoveredDevice {
name: string;
url: string;
device_type: string;
led_count?: number;
}
interface ScaffoldResult {
device_id: string;
capture_template_id: string;
picture_source_id: string;
color_strip_source_id: string;
output_target_id: string;
capture_template_reused: boolean;
}
interface WizardState {
step: WizardStep;
/** Persisted device id after creation. */
deviceId: string;
deviceName: string;
displayIndex: number;
displayName: string;
scaffoldResult: ScaffoldResult | null;
/** Populated by step 2 discovery scan. */
discoveredDevices: DiscoveredDevice[];
/** Manual-entry mode in step 2. */
manualMode: boolean;
busy: boolean;
errorMsg: string;
}
// ── Module singleton ───────────────────────────────────────────────────────────
let _state: WizardState | null = null;
let _modal: SetupWizardModal | null = null;
const ONBOARDED_KEY = 'tour_completed'; // mirror tutorials.ts TOUR_KEY
const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done'];
// ── Modal class ────────────────────────────────────────────────────────────────
class SetupWizardModal extends Modal {
constructor() {
super('setup-wizard-modal');
}
onForceClose(): void {
_handleWizardClose();
}
}
// ── Public API ─────────────────────────────────────────────────────────────────
/** Open the wizard (first-run or on-demand). */
export function openSetupWizard(): void {
if (!_modal) _modal = new SetupWizardModal();
_state = {
step: 'welcome',
deviceId: '',
deviceName: '',
displayIndex: 0,
displayName: '',
scaffoldResult: null,
discoveredDevices: [],
manualMode: false,
busy: false,
errorMsg: '',
};
_modal.open();
_renderStep();
}
/** Close the wizard and mark as complete / skipped. */
export function closeSetupWizard(): void {
if (!_modal) return;
void unmountAutoCalibration();
_modal.forceClose();
}
// ─────────────────────────────────────────────────────────────────────────────
// First-run check (called from app.ts after auth passes)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Check onboarding state and open the wizard on true first run.
*
* Returns `true` if the wizard was opened (caller should suppress the tour).
* Returns `false` if already onboarded (caller should proceed with tour logic).
*/
export async function checkAndOpenWizardIfNeeded(): Promise<boolean> {
try {
const [onboardingResp, targetsResp] = await Promise.all([
apiGet<{ onboarded: boolean; completed_at: string | null }>('/preferences/onboarding'),
outputTargetsCache.fetch().catch((): unknown[] => []),
]);
if (onboardingResp.onboarded) {
// Already onboarded — let tour run normally
return false;
}
const targets = Array.isArray(targetsResp) ? targetsResp : [];
if (targets.length > 0) {
// Has output targets but never completed onboarding wizard.
// Power user or migrated setup — mark done and skip wizard.
await _markOnboarded();
return false;
}
// True first run: no targets, not onboarded
openSetupWizard();
return true;
} catch {
// If the check itself fails (server offline, 404 on new backend, etc.)
// fall through to existing tour logic — don't block the UI.
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Onboarding flag helpers
// ─────────────────────────────────────────────────────────────────────────────
async function _markOnboarded(): Promise<void> {
try {
await apiPut('/preferences/onboarding', { onboarded: true });
// Suppress tooltip tour too — wizard owns the first-run experience
localStorage.setItem(ONBOARDED_KEY, '1');
} catch {
// Non-fatal: UI already moved on
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Wizard step navigation
// ─────────────────────────────────────────────────────────────────────────────
function _stepIndex(step: WizardStep): number {
return STEPS.indexOf(step);
}
export async function wizardNext(): Promise<void> {
if (!_state || _state.busy) return;
const step = _state.step;
if (step === 'welcome') {
_state.step = 'device';
_renderStep();
_startDiscovery();
} else if (step === 'device') {
if (!_state.deviceId) {
_setError(t('wizard.error.no_device'));
return;
}
_state.step = 'display';
_renderStep();
await _loadDisplays();
} else if (step === 'display') {
_state.step = 'scaffold';
_renderStep();
await _runScaffold();
} else if (step === 'calibrate') {
// "Skip calibration" path — move to start
void unmountAutoCalibration();
_state.step = 'start';
_renderStep();
await _startOutput();
} else if (step === 'start') {
_state.step = 'done';
_renderStep();
} else if (step === 'done') {
void closeSetupWizard();
await _markOnboarded();
}
}
export function wizardBack(): void {
if (!_state || _state.busy) return;
const idx = _stepIndex(_state.step);
if (idx <= 0) return;
// Back from calibrate: unmount the autocal component
if (_state.step === 'calibrate') {
void unmountAutoCalibration();
}
_state.step = STEPS[idx - 1];
_state.errorMsg = '';
_renderStep();
}
export function wizardSkip(): void {
if (!_state) return;
void closeSetupWizard();
void _markOnboarded();
}
// ─────────────────────────────────────────────────────────────────────────────
// Step: device discovery
// ─────────────────────────────────────────────────────────────────────────────
async function _startDiscovery(): Promise<void> {
if (!_state) return;
_state.busy = true;
_state.discoveredDevices = [];
_renderStep();
try {
const data = await apiGet<{ devices?: DiscoveredDevice[] }>('/devices/discover?timeout=3&device_type=wled');
_state.discoveredDevices = data.devices || [];
} catch {
_state.discoveredDevices = [];
} finally {
_state.busy = false;
_renderStep();
}
}
/** Switch device step to manual-entry mode. */
export function wizardShowManual(): void {
if (!_state) return;
_state.manualMode = true;
_state.errorMsg = '';
_renderStep();
}
export function wizardHideManual(): void {
if (!_state) return;
_state.manualMode = false;
_renderStep();
}
/** User clicked a discovered device — create it via POST /devices. */
export async function wizardSelectDiscovered(url: string, name: string, device_type: string): Promise<void> {
if (!_state || _state.busy) return;
_state.busy = true;
_state.errorMsg = '';
_renderStep();
try {
const body: Record<string, unknown> = {
name,
device_type,
url,
led_count: 60,
};
const device = await apiPost<Device>('/devices', body,
{ errorMessage: t('wizard.error.device_create_failed') });
_state.deviceId = device.id;
_state.deviceName = device.name;
devicesCache.invalidate();
_state.step = 'display';
_state.busy = false;
_renderStep();
await _loadDisplays();
} catch (err: unknown) {
_state.busy = false;
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
}
}
/** Manual device form submit. */
export async function wizardAddManualDevice(event: Event): Promise<void> {
event.preventDefault();
if (!_state || _state.busy) return;
const nameEl = document.getElementById('wizard-device-name') as HTMLInputElement | null;
const urlEl = document.getElementById('wizard-device-url') as HTMLInputElement | null;
const ledEl = document.getElementById('wizard-device-led-count') as HTMLInputElement | null;
const name = nameEl?.value.trim() || '';
const url = urlEl?.value.trim() || '';
const ledCount = parseInt(ledEl?.value || '60', 10) || 60;
if (!name) { _setError(t('wizard.error.device_name_required')); return; }
if (!url) { _setError(t('wizard.error.device_url_required')); return; }
_state.busy = true;
_state.errorMsg = '';
_renderStep();
try {
const device = await apiPost<Device>('/devices', {
name, url, device_type: 'wled', led_count: ledCount,
}, { errorMessage: t('wizard.error.device_create_failed') });
_state.deviceId = device.id;
_state.deviceName = device.name;
devicesCache.invalidate();
_state.step = 'display';
_state.busy = false;
_renderStep();
await _loadDisplays();
} catch (err: unknown) {
_state.busy = false;
_setError(err instanceof Error ? err.message : t('wizard.error.device_create_failed'));
}
}
/** User selected an already-existing device from the cache. */
export function wizardUseExistingDevice(deviceId: string, deviceName: string): void {
if (!_state || _state.busy) return;
_state.deviceId = deviceId;
_state.deviceName = deviceName;
_state.step = 'display';
_state.errorMsg = '';
_renderStep();
void _loadDisplays();
}
// ─────────────────────────────────────────────────────────────────────────────
// Step: display selection
// ─────────────────────────────────────────────────────────────────────────────
async function _loadDisplays(): Promise<void> {
if (!_state) return;
_state.busy = true;
_renderStep();
try {
await displaysCache.fetch();
} catch {
// Fall through — render will show a fallback
} finally {
_state.busy = false;
_renderStep();
}
}
export function wizardSelectDisplay(index: number, displayName: string): void {
if (!_state) return;
_state.displayIndex = index;
_state.displayName = displayName;
_state.errorMsg = '';
_renderStep();
}
// ─────────────────────────────────────────────────────────────────────────────
// Step: scaffold
// ─────────────────────────────────────────────────────────────────────────────
async function _runScaffold(): Promise<void> {
if (!_state) return;
_state.busy = true;
_state.errorMsg = '';
_renderStep();
try {
const result = await apiPost<ScaffoldResult>('/setup/scaffold', {
device_id: _state.deviceId,
display_index: _state.displayIndex,
calibration: null,
}, { errorMessage: t('wizard.error.scaffold_failed') });
_state.scaffoldResult = result;
_state.busy = false;
_state.step = 'calibrate';
_renderStep();
// Mount the auto-calibration component inside the calibrate step container
const container = document.getElementById('wizard-calibrate-container');
if (container) {
await mountAutoCalibration({
container,
cssId: result.color_strip_source_id,
deviceId: _state.deviceId,
onComplete: () => {
if (!_state) return;
_state.step = 'start';
_renderStep();
void _startOutput();
},
onCancel: () => {
if (!_state) return;
_state.step = 'start';
_renderStep();
void _startOutput();
},
});
}
} catch (err: unknown) {
_state.busy = false;
_setError(err instanceof Error ? err.message : t('wizard.error.scaffold_failed'));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step: start output
// ─────────────────────────────────────────────────────────────────────────────
async function _startOutput(): Promise<void> {
if (!_state?.scaffoldResult) return;
_state.busy = true;
_state.errorMsg = '';
_renderStep();
try {
await apiPost<unknown>(`/output-targets/${_state.scaffoldResult.output_target_id}/start`, {},
{ errorMessage: t('wizard.error.start_failed') });
outputTargetsCache.invalidate();
_state.busy = false;
_state.step = 'done';
_renderStep();
} catch (err: unknown) {
_state.busy = false;
// Non-fatal: still show done step but surface the error
showToast(err instanceof Error ? err.message : t('wizard.error.start_failed'), 'warning');
_state.step = 'done';
_renderStep();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────────────────────────
function _setError(msg: string): void {
if (!_state) return;
_state.errorMsg = msg;
_renderStep();
}
function _handleWizardClose(): void {
void unmountAutoCalibration();
_state = null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Rendering
// ─────────────────────────────────────────────────────────────────────────────
function _renderStep(): void {
if (!_state) return;
const container = document.getElementById('wizard-step-container');
if (!container) return;
_renderProgressBar();
const html = _buildStepHtml(_state);
container.innerHTML = html;
_attachStepListeners(_state.step);
}
function _renderProgressBar(): void {
if (!_state) return;
const bar = document.getElementById('wizard-progress-bar');
const labels = document.getElementById('wizard-progress-labels');
if (!bar || !labels) return;
const currentIdx = _stepIndex(_state.step);
// Progress bar shows steps 1-6 (skip 'done' which is the finish state)
const visibleSteps: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start'];
const total = visibleSteps.length;
const activeIdx = visibleSteps.indexOf(_state.step);
const pct = activeIdx < 0 ? 100 : Math.round(((activeIdx) / (total - 1)) * 100);
bar.innerHTML = `
<div class="wizard-progress-track">
<div class="wizard-progress-fill" style="width:${pct}%"></div>
</div>
`;
const stepLabels = visibleSteps.map((s, i) => {
const done = currentIdx > STEPS.indexOf(s);
const active = s === _state!.step;
const cls = done ? 'wizard-pip wizard-pip--done' : active ? 'wizard-pip wizard-pip--active' : 'wizard-pip';
return `<span class="${cls}" title="${t(`wizard.step.${s}`)}">${done ? ICON_CHECK : String(i + 1)}</span>`;
}).join('');
labels.innerHTML = stepLabels;
}
function _buildStepHtml(state: WizardState): string {
switch (state.step) {
case 'welcome': return _buildWelcomeStep();
case 'device': return _buildDeviceStep(state);
case 'display': return _buildDisplayStep(state);
case 'scaffold': return _buildScaffoldStep(state);
case 'calibrate':return _buildCalibrateStep(state);
case 'start': return _buildStartStep(state);
case 'done': return _buildDoneStep(state);
}
}
function _errorBanner(msg: string): string {
if (!msg) return '';
return `<div class="wizard-error">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
<span>${msg}</span>
</div>`;
}
function _buildWelcomeStep(): string {
return `<div class="wizard-step wizard-step--welcome">
<div class="wizard-welcome-icon">${ICON_SPARKLES}</div>
<h3 class="wizard-step-title">${t('wizard.welcome.title')}</h3>
<p class="wizard-step-desc">${t('wizard.welcome.desc')}</p>
<ul class="wizard-welcome-list">
<li>${ICON_DEVICE}<span>${t('wizard.welcome.item1')}</span></li>
<li>${ICON_MONITOR}<span>${t('wizard.welcome.item2')}</span></li>
<li>${ICON_CALIBRATION}<span>${t('wizard.welcome.item3')}</span></li>
<li>${ICON_START}<span>${t('wizard.welcome.item4')}</span></li>
</ul>
<div class="wizard-footer">
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
<button class="btn btn-primary" onclick="wizardNext()">${t('wizard.start')}</button>
</div>
</div>`;
}
function _buildDeviceStep(state: WizardState): string {
const existingDevices: Device[] = devicesCache.data || [];
let discoveryHtml = '';
if (state.busy && state.discoveredDevices.length === 0) {
discoveryHtml = `<div class="wizard-discovery-scanning">
<div class="loading-spinner"></div>
<span>${t('wizard.device.scanning')}</span>
</div>`;
} else if (state.discoveredDevices.length > 0) {
discoveryHtml = `<div class="wizard-discovery-list">` +
state.discoveredDevices.map(d => `
<button class="wizard-discovery-item" onclick="wizardSelectDiscovered('${_esc(d.url)}','${_esc(d.name)}','${_esc(d.device_type)}')">
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
<span class="wizard-discovery-details">
<span class="wizard-discovery-name">${_esc(d.name)}</span>
<span class="wizard-discovery-url">${_esc(d.url)}</span>
</span>
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
</button>`).join('') +
`</div>`;
} else {
discoveryHtml = `<div class="wizard-discovery-empty">
<span>${t('wizard.device.none_found')}</span>
</div>`;
}
let existingHtml = '';
if (existingDevices.length > 0) {
existingHtml = `<div class="wizard-section-label">${t('wizard.device.existing')}</div>
<div class="wizard-discovery-list">` +
existingDevices.map(d => `
<button class="wizard-discovery-item" onclick="wizardUseExistingDevice('${_esc(d.id)}','${_esc(d.name)}')">
<span class="wizard-discovery-icon">${getDeviceTypeIcon(d.device_type)}</span>
<span class="wizard-discovery-details">
<span class="wizard-discovery-name">${_esc(d.name)}</span>
<span class="wizard-discovery-url">${_esc(d.url)}</span>
</span>
<span class="wizard-discovery-badge">${_esc(d.device_type.toUpperCase())}</span>
</button>`).join('') +
`</div>`;
}
let manualHtml = '';
if (state.manualMode) {
manualHtml = `<form id="wizard-manual-form" onsubmit="wizardAddManualDevice(event)">
<div class="wizard-form-row">
<label class="wizard-form-label">${t('wizard.device.manual.name')}</label>
<input id="wizard-device-name" class="form-input" type="text" placeholder="${t('wizard.device.manual.name_placeholder')}" required>
</div>
<div class="wizard-form-row">
<label class="wizard-form-label">${t('wizard.device.manual.url')}</label>
<input id="wizard-device-url" class="form-input" type="text" placeholder="http://192.168.1.x" required>
</div>
<div class="wizard-form-row">
<label class="wizard-form-label">${t('wizard.device.manual.led_count')}</label>
<input id="wizard-device-led-count" class="form-input" type="number" min="1" max="1000" value="60">
</div>
${_errorBanner(state.errorMsg)}
<div class="wizard-footer">
<button type="button" class="btn btn-ghost" onclick="wizardHideManual()">${t('common.back')}</button>
<button type="submit" class="btn btn-primary"${state.busy ? ' disabled' : ''}>
${state.busy ? `<div class="btn-spinner"></div>` : ''}${t('wizard.device.manual.add')}
</button>
</div>
</form>`;
} else {
manualHtml = '';
}
return `<div class="wizard-step">
<div class="wizard-step-header">
<div class="wizard-step-icon">${ICON_DEVICE}</div>
<div>
<h3 class="wizard-step-title">${t('wizard.device.title')}</h3>
<p class="wizard-step-desc">${t('wizard.device.desc')}</p>
</div>
</div>
${!state.manualMode ? `
<div class="wizard-discovery-section">
<div class="wizard-section-label wizard-section-label--scan">
${t('wizard.device.discovered')}
<button class="wizard-scan-btn" onclick="wizardRescan()"${state.busy ? ' disabled' : ''}>
${ICON_SEARCH} ${t('wizard.device.rescan')}
</button>
</div>
${discoveryHtml}
</div>
${existingHtml}
${_errorBanner(state.errorMsg)}
<div class="wizard-footer">
<button class="btn btn-ghost" onclick="wizardSkip()">${t('wizard.skip')}</button>
<button class="btn btn-secondary" onclick="wizardShowManual()">
${ICON_PLUS} ${t('wizard.device.manual.title')}
</button>
</div>
` : manualHtml}
</div>`;
}
function _buildDisplayStep(state: WizardState): string {
const displays: Display[] = displaysCache.data ?? [];
let listHtml = '';
if (state.busy && displays.length === 0) {
listHtml = `<div class="wizard-discovery-scanning">
<div class="loading-spinner"></div>
<span>${t('wizard.display.loading')}</span>
</div>`;
} else if (displays.length === 0) {
// Fallback: offer a manual index input
listHtml = `<div class="wizard-display-fallback">
<p class="wizard-step-desc">${t('wizard.display.no_displays')}</p>
<div class="wizard-form-row">
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
<input id="wizard-display-index-manual" class="form-input" type="number"
min="0" max="63" value="${state.displayIndex}"
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value)">
</div>
</div>`;
} else {
listHtml = `<div class="wizard-display-list">` +
displays.map(d => {
const active = d.index === state.displayIndex;
return `<button class="wizard-display-item${active ? ' wizard-display-item--active' : ''}"
onclick="wizardSelectDisplay(${d.index}, '${_esc(d.name)}')">
<span class="wizard-display-icon">${ICON_MONITOR}</span>
<span class="wizard-display-details">
<span class="wizard-display-name">${_esc(d.name)}</span>
<span class="wizard-display-dims">${d.width} × ${d.height}${d.is_primary ? ' · ' + t('wizard.display.primary') : ''}</span>
</span>
${active ? `<span class="wizard-display-check">${ICON_CHECK}</span>` : ''}
</button>`;
}).join('') +
`</div>`;
}
return `<div class="wizard-step">
<div class="wizard-step-header">
<div class="wizard-step-icon">${ICON_MONITOR}</div>
<div>
<h3 class="wizard-step-title">${t('wizard.display.title')}</h3>
<p class="wizard-step-desc">${t('wizard.display.desc')}</p>
</div>
</div>
${listHtml}
${_errorBanner(state.errorMsg)}
<div class="wizard-footer">
<button class="btn btn-ghost" onclick="wizardBack()">${t('common.back')}</button>
<button class="btn btn-primary" onclick="wizardNext()"${state.busy ? ' disabled' : ''}>
${t('wizard.display.confirm')}
</button>
</div>
</div>`;
}
function _buildScaffoldStep(state: WizardState): string {
return `<div class="wizard-step wizard-step--scaffold">
<div class="wizard-step-header">
<div class="wizard-step-icon${state.scaffoldResult ? ' wizard-step-icon--ok' : ''}">${state.scaffoldResult ? ICON_OK : ICON_SPARKLES}</div>
<div>
<h3 class="wizard-step-title">${t('wizard.scaffold.title')}</h3>
<p class="wizard-step-desc">${state.busy ? t('wizard.scaffold.building') : state.scaffoldResult ? t('wizard.scaffold.done') : t('wizard.scaffold.desc')}</p>
</div>
</div>
${state.busy ? `<div class="wizard-scaffold-progress">
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
<span class="wizard-scaffold-label">${t('wizard.scaffold.building')}</span>
</div>` : ''}
${_errorBanner(state.errorMsg)}
</div>`;
}
function _buildCalibrateStep(state: WizardState): string {
return `<div class="wizard-step wizard-step--calibrate">
<div class="wizard-step-header">
<div class="wizard-step-icon">${ICON_CALIBRATION}</div>
<div>
<h3 class="wizard-step-title">${t('wizard.calibrate.title')}</h3>
<p class="wizard-step-desc">${t('wizard.calibrate.desc')}</p>
</div>
</div>
<!-- auto-calibration.ts mounts here -->
<div id="wizard-calibrate-container" class="wizard-calibrate-container"></div>
<div class="wizard-footer">
<button class="btn btn-ghost" onclick="wizardNext()">${t('wizard.calibrate.skip')}</button>
</div>
</div>`;
}
function _buildStartStep(state: WizardState): string {
return `<div class="wizard-step wizard-step--start">
<div class="wizard-step-header">
<div class="wizard-step-icon${!state.busy && !state.errorMsg ? ' wizard-step-icon--ok' : ''}">${START_STEP_ICON(state)}</div>
<div>
<h3 class="wizard-step-title">${t('wizard.start.title')}</h3>
<p class="wizard-step-desc">${state.busy ? t('wizard.start.starting') : state.errorMsg ? t('wizard.start.failed') : t('wizard.start.done')}</p>
</div>
</div>
${state.busy ? `<div class="wizard-scaffold-progress">
<div class="wizard-scaffold-spinner"><div class="loading-spinner"></div></div>
<span class="wizard-scaffold-label">${t('wizard.start.starting')}</span>
</div>` : ''}
${_errorBanner(state.errorMsg)}
</div>`;
}
function START_STEP_ICON(state: WizardState): string {
if (state.busy) return ICON_START;
if (state.errorMsg) return ICON_START;
return ICON_OK;
}
function _buildDoneStep(state: WizardState): string {
return `<div class="wizard-step wizard-step--done">
<div class="wizard-done-icon">${ICON_ROCKET_ICON}</div>
<h3 class="wizard-step-title">${t('wizard.done.title')}</h3>
<p class="wizard-step-desc">${t('wizard.done.desc')}</p>
${state.scaffoldResult ? `<div class="wizard-done-summary">
<div class="wizard-done-item">
<span class="wizard-done-label">${t('wizard.done.device')}</span>
<span class="wizard-done-value">${_esc(state.deviceName)}</span>
</div>
<div class="wizard-done-item">
<span class="wizard-done-label">${t('wizard.done.display')}</span>
<span class="wizard-done-value">${_esc(state.displayName || (t('wizard.display.index_prefix') + ' ' + String(state.displayIndex)))}</span>
</div>
</div>` : ''}
<div class="wizard-footer wizard-footer--done">
<button class="btn btn-primary" onclick="wizardFinish()">${t('wizard.done.finish')}</button>
</div>
</div>`;
}
function _attachStepListeners(step: WizardStep): void {
if (step === 'device') {
const form = document.getElementById('wizard-manual-form');
if (form) form.addEventListener('submit', (e) => void wizardAddManualDevice(e));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Re-scan
// ─────────────────────────────────────────────────────────────────────────────
export function wizardRescan(): void {
if (!_state || _state.step !== 'device') return;
_startDiscovery();
}
// ─────────────────────────────────────────────────────────────────────────────
// Finish
// ─────────────────────────────────────────────────────────────────────────────
export function wizardFinish(): void {
void closeSetupWizard();
void _markOnboarded();
// Reload targets tab so the new target appears immediately
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
}
// ─────────────────────────────────────────────────────────────────────────────
// Utility
// ─────────────────────────────────────────────────────────────────────────────
function _esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@@ -44,7 +44,19 @@ const calibrationTutorialSteps: TutorialStep[] = [
{ selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' }
];
const TOUR_KEY = 'tour_completed';
export const TOUR_KEY = 'tour_completed';
/**
* Suppress the getting-started tour for this session AND permanently.
*
* Called by the setup wizard when it takes over the first-run experience so
* the tour never double-fires after the wizard completes. Setting the
* localStorage key mirrors what `onClose` would do when the tour finishes
* naturally.
*/
export function suppressGettingStartedTour(): void {
localStorage.setItem(TOUR_KEY, '1');
}
const gettingStartedSteps: TutorialStep[] = [
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
+15
View File
@@ -60,6 +60,21 @@ interface Window {
selectDisplay: (...args: any[]) => any;
formatDisplayLabel: (...args: any[]) => any;
// ─── Setup Wizard ───
openSetupWizard: () => void;
closeSetupWizard: () => void;
wizardNext: () => Promise<void>;
wizardBack: () => void;
wizardSkip: () => void;
wizardFinish: () => void;
wizardShowManual: () => void;
wizardHideManual: () => void;
wizardRescan: () => void;
wizardSelectDiscovered: (url: string, name: string, device_type: string) => Promise<void>;
wizardAddManualDevice: (event: Event) => Promise<void>;
wizardUseExistingDevice: (deviceId: string, deviceName: string) => void;
wizardSelectDisplay: (index: number, displayName: string) => void;
// ─── Tutorials ───
startCalibrationTutorial: (...args: any[]) => any;
startDeviceTutorial: (...args: any[]) => any;
+62 -1
View File
@@ -674,6 +674,7 @@
"common.none_own_speed": "None (no sync)",
"common.undo": "Undo",
"common.cancel": "Cancel",
"common.back": "Back",
"common.apply": "Apply",
"common.start": "START",
"common.stop": "STOP",
@@ -3243,5 +3244,65 @@
"autocal.error.solve_failed": "Failed to solve calibration.",
"autocal.error.save_failed": "Failed to save calibration.",
"autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).",
"autocal.saved": "Calibration saved successfully."
"autocal.saved": "Calibration saved successfully.",
"wizard.modal.title": "Setup Wizard",
"wizard.rerun": "Rerun Setup Wizard",
"wizard.skip": "Skip",
"wizard.start": "Get Started",
"wizard.step.welcome": "Welcome",
"wizard.step.device": "Device",
"wizard.step.display": "Screen",
"wizard.step.scaffold": "Setup",
"wizard.step.calibrate": "Calibrate",
"wizard.step.start": "Start",
"wizard.step.done": "Done",
"wizard.welcome.title": "Welcome to LED Grab",
"wizard.welcome.desc": "Let's get your LED strip up and running in just a few steps.",
"wizard.welcome.item1": "Connect your LED controller",
"wizard.welcome.item2": "Choose your screen to capture",
"wizard.welcome.item3": "Calibrate your strip layout",
"wizard.welcome.item4": "Start the ambient light output",
"wizard.device.title": "Find Your Device",
"wizard.device.desc": "Scan the network for compatible LED controllers, or add one manually.",
"wizard.device.scanning": "Scanning network…",
"wizard.device.discovered": "Discovered on network",
"wizard.device.none_found": "No devices found. Try adding one manually.",
"wizard.device.rescan": "Rescan",
"wizard.device.existing": "Existing devices",
"wizard.device.manual.title": "Add Manually",
"wizard.device.manual.name": "Device Name",
"wizard.device.manual.name_placeholder": "My LED Strip",
"wizard.device.manual.url": "Device URL",
"wizard.device.manual.led_count": "LED Count",
"wizard.device.manual.add": "Add Device",
"wizard.display.title": "Choose Your Screen",
"wizard.display.desc": "Select the monitor or display you want to capture for ambient lighting.",
"wizard.display.loading": "Loading displays…",
"wizard.display.no_displays": "No displays detected. Enter the display index manually.",
"wizard.display.manual_index": "Display Index",
"wizard.display.primary": "Primary",
"wizard.display.index_prefix": "Display",
"wizard.display.confirm": "Use This Screen",
"wizard.scaffold.title": "Building Setup",
"wizard.scaffold.desc": "Creating the capture chain: screen source → color strip → LED output.",
"wizard.scaffold.building": "Creating entities…",
"wizard.scaffold.done": "Setup complete! Ready to calibrate.",
"wizard.calibrate.title": "Calibrate Strip Layout",
"wizard.calibrate.desc": "Tell LedGrab where your LED strip starts and how it runs around the screen.",
"wizard.calibrate.skip": "Skip Calibration",
"wizard.start.title": "Starting Output",
"wizard.start.starting": "Starting LED output…",
"wizard.start.done": "LED output is running!",
"wizard.start.failed": "Failed to start output. You can start it manually from the Targets tab.",
"wizard.done.title": "All Done!",
"wizard.done.desc": "Your ambient LED setup is active. Enjoy the light!",
"wizard.done.device": "Device",
"wizard.done.display": "Screen",
"wizard.done.finish": "Finish",
"wizard.error.no_device": "Please select or add a device first.",
"wizard.error.device_create_failed": "Failed to create device.",
"wizard.error.device_name_required": "Device name is required.",
"wizard.error.device_url_required": "Device URL is required.",
"wizard.error.scaffold_failed": "Setup failed. Please try again.",
"wizard.error.start_failed": "Failed to start LED output."
}
+62 -1
View File
@@ -731,6 +731,7 @@
"common.none_own_speed": "Нет (своя скорость)",
"common.undo": "Отменить",
"common.cancel": "Отмена",
"common.back": "Назад",
"common.apply": "Применить",
"common.start": "ПУСК",
"common.stop": "СТОП",
@@ -2925,5 +2926,65 @@
"autocal.error.solve_failed": "Не удалось вычислить калибровку.",
"autocal.error.save_failed": "Не удалось сохранить калибровку.",
"autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).",
"autocal.saved": "Калибровка успешно сохранена."
"autocal.saved": "Калибровка успешно сохранена.",
"wizard.modal.title": "Мастер настройки",
"wizard.rerun": "Запустить мастер настройки заново",
"wizard.skip": "Пропустить",
"wizard.start": "Начать",
"wizard.step.welcome": "Добро пожаловать",
"wizard.step.device": "Устройство",
"wizard.step.display": "Экран",
"wizard.step.scaffold": "Настройка",
"wizard.step.calibrate": "Калибровка",
"wizard.step.start": "Запуск",
"wizard.step.done": "Готово",
"wizard.welcome.title": "Добро пожаловать в LED Grab",
"wizard.welcome.desc": "Настроим вашу LED-ленту за несколько шагов.",
"wizard.welcome.item1": "Подключите контроллер LED",
"wizard.welcome.item2": "Выберите экран для захвата",
"wizard.welcome.item3": "Откалибруйте расположение ленты",
"wizard.welcome.item4": "Запустите подсветку",
"wizard.device.title": "Найдите устройство",
"wizard.device.desc": "Выполните сканирование сети или добавьте устройство вручную.",
"wizard.device.scanning": "Сканирование сети…",
"wizard.device.discovered": "Найдено в сети",
"wizard.device.none_found": "Устройства не найдены. Попробуйте добавить вручную.",
"wizard.device.rescan": "Повторить",
"wizard.device.existing": "Существующие устройства",
"wizard.device.manual.title": "Добавить вручную",
"wizard.device.manual.name": "Имя устройства",
"wizard.device.manual.name_placeholder": "Моя LED-лента",
"wizard.device.manual.url": "Адрес устройства",
"wizard.device.manual.led_count": "Количество светодиодов",
"wizard.device.manual.add": "Добавить устройство",
"wizard.display.title": "Выберите экран",
"wizard.display.desc": "Укажите монитор для захвата подсветки.",
"wizard.display.loading": "Загрузка дисплеев…",
"wizard.display.no_displays": "Дисплеи не найдены. Введите индекс вручную.",
"wizard.display.manual_index": "Индекс дисплея",
"wizard.display.primary": "Основной",
"wizard.display.index_prefix": "Дисплей",
"wizard.display.confirm": "Использовать этот экран",
"wizard.scaffold.title": "Создание конфигурации",
"wizard.scaffold.desc": "Создаём цепочку захвата: экран → цветовая лента → LED-выход.",
"wizard.scaffold.building": "Создание объектов…",
"wizard.scaffold.done": "Конфигурация создана! Готово к калибровке.",
"wizard.calibrate.title": "Калибровка ленты",
"wizard.calibrate.desc": "Укажите, где начинается лента и как она проходит вокруг экрана.",
"wizard.calibrate.skip": "Пропустить калибровку",
"wizard.start.title": "Запуск вывода",
"wizard.start.starting": "Запуск LED-вывода…",
"wizard.start.done": "LED-вывод работает!",
"wizard.start.failed": "Не удалось запустить. Запустите вручную на вкладке «Цели».",
"wizard.done.title": "Готово!",
"wizard.done.desc": "Ваша подсветка активна. Наслаждайтесь!",
"wizard.done.device": "Устройство",
"wizard.done.display": "Экран",
"wizard.done.finish": "Завершить",
"wizard.error.no_device": "Сначала выберите или добавьте устройство.",
"wizard.error.device_create_failed": "Не удалось создать устройство.",
"wizard.error.device_name_required": "Введите имя устройства.",
"wizard.error.device_url_required": "Введите адрес устройства.",
"wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.",
"wizard.error.start_failed": "Не удалось запустить LED-вывод."
}
+62 -1
View File
@@ -727,6 +727,7 @@
"common.none_own_speed": "无(使用自身速度)",
"common.undo": "撤销",
"common.cancel": "取消",
"common.back": "返回",
"common.apply": "应用",
"common.start": "启动",
"common.stop": "停止",
@@ -2919,5 +2920,65 @@
"autocal.error.solve_failed": "校准求解失败。",
"autocal.error.save_failed": "保存校准数据失败。",
"autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。",
"autocal.saved": "校准已成功保存。"
"autocal.saved": "校准已成功保存。",
"wizard.modal.title": "设置向导",
"wizard.rerun": "重新运行设置向导",
"wizard.skip": "跳过",
"wizard.start": "开始设置",
"wizard.step.welcome": "欢迎",
"wizard.step.device": "设备",
"wizard.step.display": "屏幕",
"wizard.step.scaffold": "配置",
"wizard.step.calibrate": "校准",
"wizard.step.start": "启动",
"wizard.step.done": "完成",
"wizard.welcome.title": "欢迎使用 LED Grab",
"wizard.welcome.desc": "只需几步,即可启动并运行您的 LED 灯带。",
"wizard.welcome.item1": "连接您的 LED 控制器",
"wizard.welcome.item2": "选择要采集的屏幕",
"wizard.welcome.item3": "校准灯带布局",
"wizard.welcome.item4": "启动氛围灯输出",
"wizard.device.title": "查找您的设备",
"wizard.device.desc": "扫描网络查找兼容的 LED 控制器,或手动添加。",
"wizard.device.scanning": "正在扫描网络…",
"wizard.device.discovered": "在网络中发现",
"wizard.device.none_found": "未找到设备。请尝试手动添加。",
"wizard.device.rescan": "重新扫描",
"wizard.device.existing": "已有设备",
"wizard.device.manual.title": "手动添加",
"wizard.device.manual.name": "设备名称",
"wizard.device.manual.name_placeholder": "我的 LED 灯带",
"wizard.device.manual.url": "设备地址",
"wizard.device.manual.led_count": "LED 数量",
"wizard.device.manual.add": "添加设备",
"wizard.display.title": "选择您的屏幕",
"wizard.display.desc": "选择用于采集氛围灯的显示器。",
"wizard.display.loading": "正在加载显示器…",
"wizard.display.no_displays": "未检测到显示器。请手动输入显示器序号。",
"wizard.display.manual_index": "显示器序号",
"wizard.display.primary": "主显示器",
"wizard.display.index_prefix": "显示器",
"wizard.display.confirm": "使用此屏幕",
"wizard.scaffold.title": "正在创建配置",
"wizard.scaffold.desc": "正在创建采集链:屏幕源 → 色带 → LED 输出。",
"wizard.scaffold.building": "正在创建实体…",
"wizard.scaffold.done": "配置完成!准备好进行校准。",
"wizard.calibrate.title": "校准灯带布局",
"wizard.calibrate.desc": "告诉 LedGrab 您的 LED 灯带从哪里开始,以及它如何绕屏幕布置。",
"wizard.calibrate.skip": "跳过校准",
"wizard.start.title": "正在启动输出",
"wizard.start.starting": "正在启动 LED 输出…",
"wizard.start.done": "LED 输出正在运行!",
"wizard.start.failed": "启动输出失败。您可以在「目标」选项卡中手动启动。",
"wizard.done.title": "全部完成!",
"wizard.done.desc": "您的氛围 LED 设置已激活。尽情享受灯光吧!",
"wizard.done.device": "设备",
"wizard.done.display": "屏幕",
"wizard.done.finish": "完成",
"wizard.error.no_device": "请先选择或添加一个设备。",
"wizard.error.device_create_failed": "创建设备失败。",
"wizard.error.device_name_required": "设备名称不能为空。",
"wizard.error.device_url_required": "设备地址不能为空。",
"wizard.error.scaffold_failed": "配置失败,请重试。",
"wizard.error.start_failed": "启动 LED 输出失败。"
}
+4
View File
@@ -75,6 +75,9 @@
<div class="header-toolbar">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
<span class="header-toolbar-sep"></span>
<button class="header-btn" id="wizard-rerun-btn" onclick="openSetupWizard()" data-i18n-title="wizard.rerun" title="Setup Wizard">
<svg class="icon" viewBox="0 0 24 24"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg>
</button>
<button class="header-btn" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
@@ -222,6 +225,7 @@
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
{% include 'modals/setup-wizard.html' %}
{% include 'modals/calibration.html' %}
{% include 'modals/advanced-calibration.html' %}
{% include 'modals/auto-calibration.html' %}
@@ -0,0 +1,30 @@
<!-- Setup Wizard Modal — first-run guided setup.
Opened automatically on first visit (app.ts checks onboarding flag)
and can be reopened via the toolbar wizard button.
Channel: accent (green) — same as the main calibration modal.
All step rendering is handled by setup-wizard.ts. -->
<div id="setup-wizard-modal" class="modal" role="dialog" aria-modal="true"
aria-labelledby="wizard-modal-title">
<div class="modal-content" style="max-width:600px;">
<div class="modal-header">
<h2 id="wizard-modal-title">
<svg class="icon" viewBox="0 0 24 24">
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
<path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/>
</svg>
<span data-i18n="wizard.modal.title">Setup Wizard</span>
</h2>
<button class="modal-close-btn" onclick="wizardSkip()"
title="Skip" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body" style="padding: 20px 24px;">
<!-- Progress bar and pip indicators -->
<div id="wizard-progress-bar" class="wizard-progress-bar"></div>
<div id="wizard-progress-labels" class="wizard-progress-labels"></div>
<!-- Step container: setup-wizard.ts mounts here -->
<div id="wizard-step-container"></div>
</div>
</div>
</div>