From 8c56921375f7baa0b7ee36b2f9866cdef0425867 Mon Sep 17 00:00:00 2001
From: Pouzor
Date: Sun, 19 Jul 2026 00:40:08 +0200
Subject: [PATCH] feat(walkthrough): add Getting Started tour with first-run
invite
Bespoke spotlight walkthrough (no new deps) that guides new users through the
main features. A version-stamped invite bubble offers the tour to new users and
re-offers it once to existing users after an upgrade; restartable from Settings.
- Multi-hole SVG-mask overlay keeps a ringed control and any open modal lit
- Steps: scan, scan history, inventory, nodes, edit, text/zone, grouping,
style, imports, and a closing recap with a GitHub link
- Scan/history/inventory/import steps inject demo data (no backend calls) and
are filtered out in standalone, which ends on a full-mode recap
- Persisted invite state via localStorage (utils/walkthrough.ts), live tour
state via zustand (stores/walkthroughStore.ts)
ha-relevant: maybe
---
frontend/src/App.tsx | 66 +++++-
.../components/modals/PendingDevicesModal.tsx | 8 +-
.../components/modals/ScanHistoryModal.tsx | 9 +-
.../src/components/modals/SettingsModal.tsx | 13 ++
.../src/components/panels/DetailPanel.tsx | 1 +
frontend/src/components/panels/Sidebar.tsx | 19 +-
frontend/src/components/panels/Toolbar.tsx | 2 +-
.../stores/__tests__/walkthroughStore.test.ts | 58 +++++
frontend/src/stores/walkthroughStore.ts | 48 ++++
.../src/utils/__tests__/walkthrough.test.ts | 66 ++++++
frontend/src/utils/walkthrough.ts | 73 ++++++
.../src/walkthrough/WalkthroughInvite.tsx | 72 ++++++
.../src/walkthrough/WalkthroughOverlay.tsx | 219 ++++++++++++++++++
.../__tests__/WalkthroughInvite.test.tsx | 46 ++++
.../__tests__/WalkthroughOverlay.test.tsx | 46 ++++
.../src/walkthrough/__tests__/steps.test.ts | 27 +++
frontend/src/walkthrough/actions.ts | 30 +++
frontend/src/walkthrough/demoTourData.ts | 65 ++++++
frontend/src/walkthrough/steps.ts | 121 ++++++++++
frontend/src/walkthrough/walkthrough.css | 29 +++
20 files changed, 1004 insertions(+), 14 deletions(-)
create mode 100644 frontend/src/stores/__tests__/walkthroughStore.test.ts
create mode 100644 frontend/src/stores/walkthroughStore.ts
create mode 100644 frontend/src/utils/__tests__/walkthrough.test.ts
create mode 100644 frontend/src/utils/walkthrough.ts
create mode 100644 frontend/src/walkthrough/WalkthroughInvite.tsx
create mode 100644 frontend/src/walkthrough/WalkthroughOverlay.tsx
create mode 100644 frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx
create mode 100644 frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx
create mode 100644 frontend/src/walkthrough/__tests__/steps.test.ts
create mode 100644 frontend/src/walkthrough/actions.ts
create mode 100644 frontend/src/walkthrough/demoTourData.ts
create mode 100644 frontend/src/walkthrough/steps.ts
create mode 100644 frontend/src/walkthrough/walkthrough.css
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5395ae8..c0fda1c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useCallback, useRef, useState } from 'react'
+import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
@@ -46,6 +46,10 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
+import { WalkthroughActionsProvider, type WalkthroughActionApi } from '@/walkthrough/actions'
+import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
+import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
+import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
@@ -82,6 +86,11 @@ export default function App() {
// entry signal for the upcoming Getting Started walkthrough.
const [isNewUser, setIsNewUser] = useState(false)
+ // Getting Started tour: when true, the corresponding modal renders injected demo
+ // data instead of hitting the backend.
+ const [tourScanHistoryDemo, setTourScanHistoryDemo] = useState(false)
+ const [tourInventoryDemo, setTourInventoryDemo] = useState(false)
+
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [styleEditorType, setStyleEditorType] = useState(null)
const [searchOpen, setSearchOpen] = useState(false)
@@ -285,6 +294,55 @@ export default function App() {
void loadDesignsAndCanvasRef.current()
}, [])
+ // Bridge the Getting Started tour steps to the App's modal controls. The overlay
+ // calls closeAll() before each step, then the step's action to open the target.
+ const walkthroughActions = useMemo(() => ({
+ closeAll: () => {
+ setScanConfigOpen(false)
+ setScanHistoryOpen(false)
+ setTourScanHistoryDemo(false)
+ setPendingModalOpen(false)
+ setTourInventoryDemo(false)
+ setEditNodeId(null)
+ setThemeModalOpen(false)
+ setZigbeeImportOpen(false)
+ // Clear any tour-driven multi-selection so the DetailPanel closes.
+ useCanvasStore.setState((s) => ({
+ nodes: s.nodes.map((n) => (n.selected ? { ...n, selected: false } : n)),
+ selectedNodeIds: [],
+ selectedNodeId: null,
+ }))
+ },
+ openScanConfig: () => setScanConfigOpen(true),
+ openScanHistoryDemo: () => {
+ setTourScanHistoryDemo(true)
+ setScanHistoryOpen(true)
+ },
+ openInventoryDemo: () => {
+ setTourInventoryDemo(true)
+ openPendingModal(undefined, 'pending')
+ },
+ editFirstNode: () => {
+ const first = useCanvasStore.getState().nodes.find((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
+ if (first) setEditNodeId(first.id)
+ },
+ selectTwoNodes: () => {
+ const ids = useCanvasStore.getState().nodes
+ .filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
+ .slice(0, 2)
+ .map((n) => n.id)
+ if (ids.length < 2) return
+ const set = new Set(ids)
+ useCanvasStore.setState((s) => ({
+ nodes: s.nodes.map((n) => ({ ...n, selected: set.has(n.id) })),
+ selectedNodeIds: ids,
+ selectedNodeId: null,
+ }))
+ },
+ openStyle: () => setThemeModalOpen(true),
+ openZigbeeImport: () => setZigbeeImportOpen(true),
+ }), [openPendingModal])
+
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
useEffect(() => {
@@ -867,6 +925,7 @@ export default function App() {
if (!STANDALONE && !isAuthenticated) return
return (
+
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
@@ -1036,6 +1095,7 @@ export default function App() {
setScanHistoryOpen(false)}
+ demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
/>
)}
@@ -1160,6 +1220,7 @@ export default function App() {
onClose={() => setPendingModalOpen(false)}
highlightId={pendingHighlightId}
initialStatus={pendingModalStatus}
+ demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
/>
+
+
+
)
}
diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx
index 1ea9fd2..8c2da0b 100644
--- a/frontend/src/components/modals/PendingDevicesModal.tsx
+++ b/frontend/src/components/modals/PendingDevicesModal.tsx
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
onClose: () => void
highlightId?: string
initialStatus?: 'pending' | 'hidden'
+ /** Getting Started tour: render these canned devices and skip the backend fetch. */
+ demoDevices?: PendingDevice[]
}
const PORT_COLORS: Record = {
@@ -113,7 +115,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
}))
}
-export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
+export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices }: PendingDevicesModalProps) {
const [devices, setDevices] = useState([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState(null)
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
const load = useCallback(async () => {
+ // Tour/demo mode: show injected devices, never touch the backend.
+ if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
setLoading(true)
try {
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
} finally {
setLoading(false)
}
- }, [statusFilter])
+ }, [statusFilter, demoDevices])
useEffect(() => { if (open) load() }, [open, load])
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
diff --git a/frontend/src/components/modals/ScanHistoryModal.tsx b/frontend/src/components/modals/ScanHistoryModal.tsx
index 8dabc09..584e12a 100644
--- a/frontend/src/components/modals/ScanHistoryModal.tsx
+++ b/frontend/src/components/modals/ScanHistoryModal.tsx
@@ -20,6 +20,8 @@ export interface ScanRun {
interface ScanHistoryModalProps {
open: boolean
onClose: () => void
+ /** Getting Started tour: render these canned runs and skip the backend fetch. */
+ demoRuns?: ScanRun[]
}
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
return formatDuration(end - start)
}
-export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
+export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
const [runs, setRuns] = useState([])
const [loading, setLoading] = useState(false)
const [stopping, setStopping] = useState(null)
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
const prevRunsRef = useRef([])
const load = useCallback(async () => {
+ // Tour/demo mode: show injected runs, never touch the backend.
+ if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
setLoading(true)
try {
const res = await scanApi.runs()
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
} finally {
setLoading(false)
}
- }, [])
+ }, [demoRuns])
// Load when opened; reset prior-state tracker so we don't replay old transitions
useEffect(() => {
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
}, [open, runs])
const handleStop = async (runId: string) => {
+ if (demoRuns) return // tour mode — no backend
setStopping(runId)
try {
await scanApi.stop(runId)
diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx
index 58f98a5..55ccbe9 100644
--- a/frontend/src/components/modals/SettingsModal.tsx
+++ b/frontend/src/components/modals/SettingsModal.tsx
@@ -11,6 +11,7 @@ import {
type ZwaveConfigData,
} from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
+import { useWalkthroughStore } from '@/stores/walkthroughStore'
import { toast } from 'sonner'
import {
type AlignmentSettings,
@@ -401,6 +402,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
+
+
+ Getting started tour
+
+
diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx
index 2a65e19..68e88e8 100644
--- a/frontend/src/components/panels/DetailPanel.tsx
+++ b/frontend/src/components/panels/DetailPanel.tsx
@@ -516,6 +516,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
>
) : (