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:
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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-вывод."
|
||||
}
|
||||
|
||||
@@ -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 输出失败。"
|
||||
}
|
||||
|
||||
@@ -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">✕</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>
|
||||
Reference in New Issue
Block a user