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
This commit is contained in:
+65
-1
@@ -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 { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
@@ -46,6 +46,10 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
|||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision'
|
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 { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
||||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/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.
|
// entry signal for the upcoming Getting Started walkthrough.
|
||||||
const [isNewUser, setIsNewUser] = useState(false)
|
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 [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
@@ -285,6 +294,55 @@ export default function App() {
|
|||||||
void loadDesignsAndCanvasRef.current()
|
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<WalkthroughActionApi>(() => ({
|
||||||
|
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
|
// Load designs + canvas on auth (or immediately in standalone mode, which has
|
||||||
// no auth gate).
|
// no auth gate).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -867,6 +925,7 @@ export default function App() {
|
|||||||
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
if (!STANDALONE && !isAuthenticated) return <LoginPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WalkthroughActionsProvider value={walkthroughActions}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
|
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
|
||||||
@@ -1036,6 +1095,7 @@ export default function App() {
|
|||||||
<ScanHistoryModal
|
<ScanHistoryModal
|
||||||
open={scanHistoryOpen}
|
open={scanHistoryOpen}
|
||||||
onClose={() => setScanHistoryOpen(false)}
|
onClose={() => setScanHistoryOpen(false)}
|
||||||
|
demoRuns={tourScanHistoryDemo ? DEMO_SCAN_RUNS : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1160,6 +1220,7 @@ export default function App() {
|
|||||||
onClose={() => setPendingModalOpen(false)}
|
onClose={() => setPendingModalOpen(false)}
|
||||||
highlightId={pendingHighlightId}
|
highlightId={pendingHighlightId}
|
||||||
initialStatus={pendingModalStatus}
|
initialStatus={pendingModalStatus}
|
||||||
|
demoDevices={tourInventoryDemo ? DEMO_PENDING_DEVICES : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
@@ -1169,7 +1230,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
|
<WalkthroughInvite />
|
||||||
|
<WalkthroughOverlay />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</WalkthroughActionsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface PendingDevicesModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
highlightId?: string
|
highlightId?: string
|
||||||
initialStatus?: 'pending' | 'hidden'
|
initialStatus?: 'pending' | 'hidden'
|
||||||
|
/** Getting Started tour: render these canned devices and skip the backend fetch. */
|
||||||
|
demoDevices?: PendingDevice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT_COLORS: Record<number, string> = {
|
const PORT_COLORS: Record<number, string> = {
|
||||||
@@ -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<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
@@ -136,6 +138,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
// Tour/demo mode: show injected devices, never touch the backend.
|
||||||
|
if (demoDevices) { setDevices(statusFilter === 'pending' ? demoDevices : []); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||||
@@ -145,7 +149,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [statusFilter])
|
}, [statusFilter, demoDevices])
|
||||||
|
|
||||||
useEffect(() => { if (open) load() }, [open, load])
|
useEffect(() => { if (open) load() }, [open, load])
|
||||||
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ScanRun {
|
|||||||
interface ScanHistoryModalProps {
|
interface ScanHistoryModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
/** Getting Started tour: render these canned runs and skip the backend fetch. */
|
||||||
|
demoRuns?: ScanRun[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||||
@@ -85,7 +87,7 @@ function runDuration(r: ScanRun, now: number): string {
|
|||||||
return formatDuration(end - start)
|
return formatDuration(end - start)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
export function ScanHistoryModal({ open, onClose, demoRuns }: ScanHistoryModalProps) {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [stopping, setStopping] = useState<string | null>(null)
|
const [stopping, setStopping] = useState<string | null>(null)
|
||||||
@@ -95,6 +97,8 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
const prevRunsRef = useRef<ScanRun[]>([])
|
const prevRunsRef = useRef<ScanRun[]>([])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
// Tour/demo mode: show injected runs, never touch the backend.
|
||||||
|
if (demoRuns) { setRuns(demoRuns); prevRunsRef.current = demoRuns; return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await scanApi.runs()
|
const res = await scanApi.runs()
|
||||||
@@ -127,7 +131,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [demoRuns])
|
||||||
|
|
||||||
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,6 +159,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
}, [open, runs])
|
}, [open, runs])
|
||||||
|
|
||||||
const handleStop = async (runId: string) => {
|
const handleStop = async (runId: string) => {
|
||||||
|
if (demoRuns) return // tour mode — no backend
|
||||||
setStopping(runId)
|
setStopping(runId)
|
||||||
try {
|
try {
|
||||||
await scanApi.stop(runId)
|
await scanApi.stop(runId)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type ZwaveConfigData,
|
type ZwaveConfigData,
|
||||||
} from '@/api/client'
|
} from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
type AlignmentSettings,
|
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.
|
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-foreground">Getting started tour</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={() => { useWalkthroughStore.getState().start(); onClose() }}
|
||||||
|
>
|
||||||
|
Restart walkthrough
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -516,6 +516,7 @@ function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGro
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
data-tour="create-group"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -268,13 +268,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} dataTour="add-zone" />
|
||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} dataTour="add-text" />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} dataTour="scan-network" />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} dataTour="zigbee-import" />}
|
||||||
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
|
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} dataTour="zwave-import" />}
|
||||||
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} />}
|
{!STANDALONE && <SidebarItem icon={Server} label="Proxmox Import" collapsed={collapsed} onClick={onProxmoxImport} dataTour="proxmox-import" />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
@@ -360,12 +360,15 @@ interface SidebarItemProps {
|
|||||||
badge?: boolean
|
badge?: boolean
|
||||||
accent?: boolean
|
accent?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
/** Stable anchor for the Getting Started walkthrough spotlight. */
|
||||||
|
dataTour?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick }: SidebarItemProps) {
|
function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onClick, dataTour }: SidebarItemProps) {
|
||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
data-tour={dataTour}
|
||||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||||
active
|
active
|
||||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
<Button data-tour="style" size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useWalkthroughStore } from '../walkthroughStore'
|
||||||
|
import { readWalkthrough, WALKTHROUGH_VERSION } from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('walkthroughStore', () => {
|
||||||
|
it('start activates at step 0', () => {
|
||||||
|
useWalkthroughStore.getState().start()
|
||||||
|
const s = useWalkthroughStore.getState()
|
||||||
|
expect(s.isActive).toBe(true)
|
||||||
|
expect(s.stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('next advances and finishes past the last step', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.setTotal(3)
|
||||||
|
store.next()
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(1)
|
||||||
|
store.next()
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
|
||||||
|
// At the last step, next() finishes the tour and stamps it completed.
|
||||||
|
store.next()
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: WALKTHROUGH_VERSION, status: 'completed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prev clamps at 0', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.setTotal(3)
|
||||||
|
store.prev()
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goTo clamps within [0, total-1]', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.setTotal(3)
|
||||||
|
store.goTo(9)
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(2)
|
||||||
|
store.goTo(-1)
|
||||||
|
expect(useWalkthroughStore.getState().stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skip deactivates and stamps skipped', () => {
|
||||||
|
const store = useWalkthroughStore.getState()
|
||||||
|
store.start()
|
||||||
|
store.skip()
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||||
|
expect(readWalkthrough().status).toBe('skipped')
|
||||||
|
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { markWalkthroughSeen } from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime state of an active Getting Started tour. Persistence of the "already
|
||||||
|
* seen" flag lives in utils/walkthrough.ts; this store only tracks the live run.
|
||||||
|
*
|
||||||
|
* `total` is set by the overlay from the mode-filtered step list, so next()/goTo
|
||||||
|
* can clamp and finish() fires when the user advances past the last step.
|
||||||
|
*/
|
||||||
|
interface WalkthroughStore {
|
||||||
|
isActive: boolean
|
||||||
|
stepIndex: number
|
||||||
|
total: number
|
||||||
|
setTotal: (n: number) => void
|
||||||
|
start: () => void
|
||||||
|
next: () => void
|
||||||
|
prev: () => void
|
||||||
|
goTo: (n: number) => void
|
||||||
|
skip: () => void
|
||||||
|
finish: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWalkthroughStore = create<WalkthroughStore>((set, get) => ({
|
||||||
|
isActive: false,
|
||||||
|
stepIndex: 0,
|
||||||
|
total: 0,
|
||||||
|
setTotal: (n) => set({ total: n }),
|
||||||
|
start: () => set({ isActive: true, stepIndex: 0 }),
|
||||||
|
next: () => {
|
||||||
|
const { stepIndex, total } = get()
|
||||||
|
if (stepIndex >= total - 1) {
|
||||||
|
get().finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set({ stepIndex: stepIndex + 1 })
|
||||||
|
},
|
||||||
|
prev: () => set((s) => ({ stepIndex: Math.max(0, s.stepIndex - 1) })),
|
||||||
|
goTo: (n) => set((s) => ({ stepIndex: Math.max(0, Math.min(n, Math.max(0, s.total - 1))) })),
|
||||||
|
skip: () => {
|
||||||
|
markWalkthroughSeen('skipped')
|
||||||
|
set({ isActive: false })
|
||||||
|
},
|
||||||
|
finish: () => {
|
||||||
|
markWalkthroughSeen('completed')
|
||||||
|
set({ isActive: false })
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
WALKTHROUGH_VERSION,
|
||||||
|
readWalkthrough,
|
||||||
|
writeWalkthrough,
|
||||||
|
subscribeWalkthrough,
|
||||||
|
shouldOfferWalkthrough,
|
||||||
|
markWalkthroughSeen,
|
||||||
|
resetWalkthrough,
|
||||||
|
} from '../walkthrough'
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
|
||||||
|
describe('readWalkthrough', () => {
|
||||||
|
it('defaults to pending / unseen when nothing is stored', () => {
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips written state', () => {
|
||||||
|
writeWalkthrough({ seenVersion: 3, status: 'completed' })
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: 3, status: 'completed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to defaults on malformed json', () => {
|
||||||
|
localStorage.setItem('homelable.walkthrough', '{not json')
|
||||||
|
expect(readWalkthrough()).toEqual({ seenVersion: null, status: 'pending' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldOfferWalkthrough', () => {
|
||||||
|
it('offers a brand-new user (seenVersion null)', () => {
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers an upgrader whose seen version is behind', () => {
|
||||||
|
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION - 1, status: 'completed' })
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops offering once the current version is stamped', () => {
|
||||||
|
markWalkthroughSeen('completed')
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(false)
|
||||||
|
expect(readWalkthrough().seenVersion).toBe(WALKTHROUGH_VERSION)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetWalkthrough', () => {
|
||||||
|
it('re-offers after a reset', () => {
|
||||||
|
markWalkthroughSeen('skipped')
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(false)
|
||||||
|
resetWalkthrough()
|
||||||
|
expect(shouldOfferWalkthrough()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscribeWalkthrough', () => {
|
||||||
|
it('notifies listeners on write and unsubscribes cleanly', () => {
|
||||||
|
const spy = vi.fn()
|
||||||
|
const unsub = subscribeWalkthrough(spy)
|
||||||
|
writeWalkthrough({ seenVersion: 1, status: 'completed' })
|
||||||
|
expect(spy).toHaveBeenCalledWith({ seenVersion: 1, status: 'completed' })
|
||||||
|
unsub()
|
||||||
|
writeWalkthrough({ seenVersion: 2, status: 'skipped' })
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Getting Started walkthrough — persisted invite state (localStorage).
|
||||||
|
*
|
||||||
|
* Mirrors the autosaveSettings.ts pattern: a `homelable.<name>` key plus a
|
||||||
|
* `window` CustomEvent for same-tab sync, so App and SettingsModal stay aligned
|
||||||
|
* without a global store.
|
||||||
|
*
|
||||||
|
* `seenVersion` is stamped with WALKTHROUGH_VERSION when the user finishes or
|
||||||
|
* dismisses the tour. Bump WALKTHROUGH_VERSION whenever new steps ship so
|
||||||
|
* existing users are re-offered the tour exactly once (they may have missed the
|
||||||
|
* new features). A brand-new user starts at seenVersion=null and is offered too.
|
||||||
|
*/
|
||||||
|
export const WALKTHROUGH_VERSION = 1
|
||||||
|
|
||||||
|
export type WalkthroughStatus = 'pending' | 'skipped' | 'completed'
|
||||||
|
|
||||||
|
export interface WalkthroughState {
|
||||||
|
seenVersion: number | null
|
||||||
|
status: WalkthroughStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = 'homelable.walkthrough'
|
||||||
|
const EVENT = 'homelable:walkthrough-changed'
|
||||||
|
|
||||||
|
export const DEFAULT_WALKTHROUGH_STATE: WalkthroughState = { seenVersion: null, status: 'pending' }
|
||||||
|
|
||||||
|
function isStatus(v: unknown): v is WalkthroughStatus {
|
||||||
|
return v === 'pending' || v === 'skipped' || v === 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readWalkthrough(): WalkthroughState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return { ...DEFAULT_WALKTHROUGH_STATE }
|
||||||
|
const parsed = JSON.parse(raw) as Partial<WalkthroughState>
|
||||||
|
return {
|
||||||
|
seenVersion: typeof parsed.seenVersion === 'number' ? parsed.seenVersion : null,
|
||||||
|
status: isStatus(parsed.status) ? parsed.status : 'pending',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_WALKTHROUGH_STATE }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWalkthrough(state: WalkthroughState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(state))
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT, { detail: state }))
|
||||||
|
} catch {
|
||||||
|
// ignore quota / SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeWalkthrough(listener: (s: WalkthroughState) => void): () => void {
|
||||||
|
const handler = (e: Event) => listener((e as CustomEvent<WalkthroughState>).detail)
|
||||||
|
window.addEventListener(EVENT, handler)
|
||||||
|
return () => window.removeEventListener(EVENT, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the invite should be offered (new user, or upgraded past the last seen version). */
|
||||||
|
export function shouldOfferWalkthrough(state: WalkthroughState = readWalkthrough()): boolean {
|
||||||
|
return state.seenVersion !== WALKTHROUGH_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp the current version so the invite stops auto-showing until the next bump. */
|
||||||
|
export function markWalkthroughSeen(status: Exclude<WalkthroughStatus, 'pending'>): void {
|
||||||
|
writeWalkthrough({ seenVersion: WALKTHROUGH_VERSION, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the stamp so the tour is offered again (used by "Restart" in Settings). */
|
||||||
|
export function resetWalkthrough(): void {
|
||||||
|
writeWalkthrough({ ...DEFAULT_WALKTHROUGH_STATE })
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Compass, X } from 'lucide-react'
|
||||||
|
import './walkthrough.css'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
import {
|
||||||
|
readWalkthrough,
|
||||||
|
subscribeWalkthrough,
|
||||||
|
shouldOfferWalkthrough,
|
||||||
|
markWalkthroughSeen,
|
||||||
|
type WalkthroughState,
|
||||||
|
} from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run invite bubble (bottom-right, above the toaster).
|
||||||
|
*
|
||||||
|
* - Not now → hide for this session only (no write), reappears next load
|
||||||
|
* - Don't show again → persist as skipped, stops offering until the next version
|
||||||
|
* - Getting started → launch the tour
|
||||||
|
*/
|
||||||
|
export function WalkthroughInvite() {
|
||||||
|
const [state, setState] = useState<WalkthroughState>(readWalkthrough)
|
||||||
|
useEffect(() => subscribeWalkthrough(setState), [])
|
||||||
|
|
||||||
|
// "Not now" dismissal — session-local, not persisted.
|
||||||
|
const [dismissedThisSession, setDismissedThisSession] = useState(false)
|
||||||
|
|
||||||
|
const isActive = useWalkthroughStore((s) => s.isActive)
|
||||||
|
const start = useWalkthroughStore((s) => s.start)
|
||||||
|
|
||||||
|
if (isActive || dismissedThisSession || !shouldOfferWalkthrough(state)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 right-4 z-[90] w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl walkthrough-invite-enter">
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissedThisSession(true)}
|
||||||
|
aria-label="Not now"
|
||||||
|
className="absolute top-2 right-2 text-muted-foreground hover:text-foreground p-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#00d4ff]/10 text-[#00d4ff]">
|
||||||
|
<Compass size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">New here? Take the tour</h3>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
A 2-minute walkthrough of scanning, devices, and building your canvas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => markWalkthroughSeen('skipped')}
|
||||||
|
>
|
||||||
|
Don't show again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={start}
|
||||||
|
>
|
||||||
|
Getting started
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ArrowLeft, ArrowRight, X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
import { useWalkthroughActions } from './actions'
|
||||||
|
import { getSteps, type StepPlacement } from './steps'
|
||||||
|
import './walkthrough.css'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
|
const CARD_W = 320
|
||||||
|
const CARD_H_EST = 210
|
||||||
|
const GAP = 16
|
||||||
|
const PAD = 6 // spotlight padding around the target
|
||||||
|
|
||||||
|
/** Coach-card position from the target rect + requested placement. */
|
||||||
|
function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: number; top: number } {
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const clampL = (l: number) => Math.max(8, Math.min(l, vw - CARD_W - 8))
|
||||||
|
const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8))
|
||||||
|
if (!rect) return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
|
||||||
|
|
||||||
|
let side = placement
|
||||||
|
if (side === 'auto') {
|
||||||
|
side = rect.right + GAP + CARD_W < vw ? 'right' : rect.left - GAP - CARD_W > 0 ? 'left' : 'bottom'
|
||||||
|
}
|
||||||
|
switch (side) {
|
||||||
|
case 'right': return { left: clampL(rect.right + GAP), top: clampT(rect.top) }
|
||||||
|
case 'left': return { left: clampL(rect.left - GAP - CARD_W), top: clampT(rect.top) }
|
||||||
|
case 'top': return { left: clampL(rect.left), top: clampT(rect.top - GAP - CARD_H_EST) }
|
||||||
|
case 'bottom': return { left: clampL(rect.left), top: clampT(rect.bottom + GAP) }
|
||||||
|
case 'center':
|
||||||
|
default: return { left: clampL((vw - CARD_W) / 2), top: clampT((vh - CARD_H_EST) / 2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WalkthroughOverlay() {
|
||||||
|
const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore()
|
||||||
|
const actions = useWalkthroughActions()
|
||||||
|
const steps = useMemo(() => getSteps(STANDALONE), [])
|
||||||
|
const step = steps[stepIndex]
|
||||||
|
|
||||||
|
// anchorRect: the element the step rings (sidebar/toolbar button). dialogRect:
|
||||||
|
// any open app modal, always kept lit so it stays readable during modal steps.
|
||||||
|
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
|
||||||
|
const [dialogRect, setDialogRect] = useState<DOMRect | null>(null)
|
||||||
|
|
||||||
|
// Keep the store's total in sync with the mode-filtered step list.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) setTotal(steps.length)
|
||||||
|
}, [isActive, steps.length, setTotal])
|
||||||
|
|
||||||
|
// Run the step's action on enter (close leftovers, open the right modal / inject
|
||||||
|
// demo data). Close everything when the tour ends.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actions) return
|
||||||
|
if (!isActive) { actions.closeAll(); return }
|
||||||
|
actions.closeAll()
|
||||||
|
const key = step?.action as keyof typeof actions | undefined
|
||||||
|
if (key && typeof actions[key] === 'function') (actions[key] as () => void)()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isActive, stepIndex, actions])
|
||||||
|
|
||||||
|
// Track target rects. Polls each frame because modals mount asynchronously after
|
||||||
|
// the step action fires, and layout shifts as they animate in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
let raf = 0
|
||||||
|
const update = () => {
|
||||||
|
const anchorEl = step?.anchor ? document.querySelector(step.anchor) : null
|
||||||
|
setAnchorRect(anchorEl ? anchorEl.getBoundingClientRect() : null)
|
||||||
|
// Any open app modal, excluding our own coach card (inside the overlay).
|
||||||
|
const dialogEl = Array.from(document.querySelectorAll('[role="dialog"]'))
|
||||||
|
.find((el) => !el.closest('[data-walkthrough-overlay]'))
|
||||||
|
setDialogRect(dialogEl ? dialogEl.getBoundingClientRect() : null)
|
||||||
|
raf = window.requestAnimationFrame(update)
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
window.addEventListener('scroll', update, true)
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(raf)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
window.removeEventListener('scroll', update, true)
|
||||||
|
}
|
||||||
|
}, [isActive, stepIndex, step?.anchor])
|
||||||
|
|
||||||
|
const handleKey = useCallback((e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); next() }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); prev() }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); skip() }
|
||||||
|
}, [next, prev, skip])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
window.addEventListener('keydown', handleKey)
|
||||||
|
return () => window.removeEventListener('keydown', handleKey)
|
||||||
|
}, [isActive, handleKey])
|
||||||
|
|
||||||
|
if (!isActive || !step) return null
|
||||||
|
|
||||||
|
const isLast = stepIndex >= total - 1
|
||||||
|
// Ring / card follow the anchor if the step has one, else the open modal.
|
||||||
|
const ringRect = anchorRect ?? dialogRect
|
||||||
|
const pos = cardPosition(ringRect, step.placement ?? 'auto')
|
||||||
|
// Cut-outs keep both the ringed control and any open modal lit.
|
||||||
|
const holes = [anchorRect, dialogRect].filter((r): r is DOMRect => r !== null)
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[100]" data-walkthrough-overlay>
|
||||||
|
{/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so
|
||||||
|
a ringed button AND an open modal can both stay readable). */}
|
||||||
|
<svg className="pointer-events-none absolute inset-0 h-full w-full">
|
||||||
|
<defs>
|
||||||
|
<mask id="wt-spotlight-mask">
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||||
|
{holes.map((r, i) => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={r.left - PAD}
|
||||||
|
y={r.top - PAD}
|
||||||
|
width={r.width + PAD * 2}
|
||||||
|
height={r.height + PAD * 2}
|
||||||
|
rx="10"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="100%" height="100%" fill="#010409" fillOpacity="0.72" mask="url(#wt-spotlight-mask)" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Pulsing ring on the primary target */}
|
||||||
|
{ringRect && (
|
||||||
|
<div
|
||||||
|
className="walkthrough-spotlight--pulse pointer-events-none absolute rounded-lg border-2 border-[#00d4ff]"
|
||||||
|
style={{
|
||||||
|
left: ringRect.left - PAD,
|
||||||
|
top: ringRect.top - PAD,
|
||||||
|
width: ringRect.width + PAD * 2,
|
||||||
|
height: ringRect.height + PAD * 2,
|
||||||
|
transition: 'left 0.25s, top 0.25s, width 0.25s, height 0.25s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coach card */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-label="Getting started walkthrough"
|
||||||
|
className="walkthrough-card-enter absolute pointer-events-auto rounded-xl border border-[#30363d] bg-[#161b22] p-4 shadow-2xl"
|
||||||
|
style={{ left: pos.left, top: pos.top, width: CARD_W, transition: 'left 0.25s, top 0.25s' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{step.title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={skip}
|
||||||
|
aria-label="Skip walkthrough"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{step.body}</p>
|
||||||
|
|
||||||
|
{step.link && (
|
||||||
|
<a
|
||||||
|
href={step.link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-[#00d4ff] hover:underline"
|
||||||
|
>
|
||||||
|
{step.link.label} <ArrowRight size={12} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-2">
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<span
|
||||||
|
key={s.id}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
i === stepIndex ? 'w-4 bg-[#00d4ff]' : 'w-1.5 bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="mr-1 text-[10px] font-mono text-muted-foreground">
|
||||||
|
{stepIndex + 1}/{steps.length}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Previous step"
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||||
|
onClick={prev}
|
||||||
|
disabled={stepIndex === 0}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={next}
|
||||||
|
>
|
||||||
|
{isLast ? 'Finish' : <>Next <ArrowRight size={13} className="ml-1" /></>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { renderWithProviders, screen, fireEvent } from '@/test/render'
|
||||||
|
import { WalkthroughInvite } from '../WalkthroughInvite'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
import { readWalkthrough } from '@/utils/walkthrough'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WalkthroughInvite', () => {
|
||||||
|
it('offers the tour to a new user', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
expect(screen.getByText('New here? Take the tour')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden once already seen', () => {
|
||||||
|
localStorage.setItem('homelable.walkthrough', JSON.stringify({ seenVersion: 1, status: 'completed' }))
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Getting started" launches the tour', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
fireEvent.click(screen.getByText('Getting started'))
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(true)
|
||||||
|
// Invite hides while the tour is active.
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Don\'t show again" persists and hides', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
fireEvent.click(screen.getByText("Don't show again"))
|
||||||
|
expect(readWalkthrough().status).toBe('skipped')
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Not now" hides without persisting', () => {
|
||||||
|
renderWithProviders(<WalkthroughInvite />)
|
||||||
|
fireEvent.click(screen.getByLabelText('Not now'))
|
||||||
|
expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument()
|
||||||
|
// Not persisted → would be offered again on next load.
|
||||||
|
expect(readWalkthrough().seenVersion).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { renderWithProviders, screen, fireEvent, cleanup, act } from '@/test/render'
|
||||||
|
import { WalkthroughOverlay } from '../WalkthroughOverlay'
|
||||||
|
import { getSteps } from '../steps'
|
||||||
|
import { useWalkthroughStore } from '@/stores/walkthroughStore'
|
||||||
|
|
||||||
|
const TOTAL = getSteps(false).length
|
||||||
|
|
||||||
|
const startTour = () => act(() => { useWalkthroughStore.getState().start() })
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
useWalkthroughStore.setState({ isActive: false, stepIndex: 0, total: 0 })
|
||||||
|
})
|
||||||
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
|
describe('WalkthroughOverlay', () => {
|
||||||
|
it('renders nothing while inactive', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the first step and progress when active', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
startTour()
|
||||||
|
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('advances and rewinds between steps', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
startTour()
|
||||||
|
fireEvent.click(screen.getByText('Next'))
|
||||||
|
expect(screen.getByText('Scan your network')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByLabelText('Previous step'))
|
||||||
|
expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skip closes the overlay', () => {
|
||||||
|
renderWithProviders(<WalkthroughOverlay />)
|
||||||
|
startTour()
|
||||||
|
fireEvent.click(screen.getByLabelText('Skip walkthrough'))
|
||||||
|
expect(useWalkthroughStore.getState().isActive).toBe(false)
|
||||||
|
expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getSteps, STEPS } from '../steps'
|
||||||
|
|
||||||
|
describe('getSteps', () => {
|
||||||
|
it('returns every step in full mode', () => {
|
||||||
|
expect(getSteps(false)).toHaveLength(STEPS.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops backend-only (mode:full) steps in standalone', () => {
|
||||||
|
const standalone = getSteps(true)
|
||||||
|
expect(standalone.every((s) => s.mode !== 'full')).toBe(true)
|
||||||
|
expect(standalone.length).toBeLessThan(STEPS.length)
|
||||||
|
const ids = standalone.map((s) => s.id)
|
||||||
|
// Canvas-only steps survive; backend-only steps are filtered out.
|
||||||
|
expect(ids).toEqual(expect.arrayContaining(['welcome', 'nodes', 'grouping', 'style', 'end']))
|
||||||
|
expect(ids).not.toContain('scan')
|
||||||
|
expect(ids).not.toContain('scan-history')
|
||||||
|
expect(ids).not.toContain('inventory')
|
||||||
|
expect(ids).not.toContain('imports')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ends on a step with a GitHub link (the full-mode recap in standalone)', () => {
|
||||||
|
const last = getSteps(true).at(-1)
|
||||||
|
expect(last?.id).toBe('end')
|
||||||
|
expect(last?.link?.href).toContain('github.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge between tour steps and the App's modal/canvas controls. App builds the
|
||||||
|
* concrete implementation from its useState setters and provides it here; the
|
||||||
|
* overlay resolves each step's `action` string to a function on this object.
|
||||||
|
*
|
||||||
|
* `closeAll` is called before every step's action so leftover modals from the
|
||||||
|
* previous step are dismissed.
|
||||||
|
*
|
||||||
|
* Grows as steps land (openInventoryDemo, editDemoNode, openStyle, …).
|
||||||
|
*/
|
||||||
|
export interface WalkthroughActionApi {
|
||||||
|
closeAll: () => void
|
||||||
|
openScanConfig: () => void
|
||||||
|
openScanHistoryDemo: () => void
|
||||||
|
openInventoryDemo: () => void
|
||||||
|
editFirstNode: () => void
|
||||||
|
selectTwoNodes: () => void
|
||||||
|
openStyle: () => void
|
||||||
|
openZigbeeImport: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WalkthroughActionsContext = createContext<WalkthroughActionApi | null>(null)
|
||||||
|
|
||||||
|
export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider
|
||||||
|
|
||||||
|
export function useWalkthroughActions(): WalkthroughActionApi | null {
|
||||||
|
return useContext(WalkthroughActionsContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ScanRun } from '@/components/modals/ScanHistoryModal'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canned data injected into the real modals during the tour, so the demo steps
|
||||||
|
* look alive on a fresh install without hitting the backend. Never persisted.
|
||||||
|
*/
|
||||||
|
export const DEMO_SCAN_RUNS: ScanRun[] = [
|
||||||
|
{
|
||||||
|
id: 'tour-scan-running',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 12,
|
||||||
|
// 8s ago so the live "Duration" reads a realistic elapsed time.
|
||||||
|
started_at: new Date(Date.now() - 8000).toISOString(),
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tour-scan-done',
|
||||||
|
status: 'done',
|
||||||
|
kind: 'ip',
|
||||||
|
ranges: ['192.168.1.0/24'],
|
||||||
|
devices_found: 9,
|
||||||
|
started_at: new Date(Date.now() - 3600_000).toISOString(),
|
||||||
|
finished_at: new Date(Date.now() - 3560_000).toISOString(),
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_PENDING_DEVICES: PendingDevice[] = [
|
||||||
|
{
|
||||||
|
id: 'tour-dev-1',
|
||||||
|
ip: '192.168.1.20',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:20',
|
||||||
|
hostname: 'synology-nas',
|
||||||
|
os: 'DSM 7.2',
|
||||||
|
services: [{ port: 5000, protocol: 'tcp', service_name: 'Synology DSM', category: 'nas' }],
|
||||||
|
suggested_type: 'nas',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
friendly_name: 'Synology NAS',
|
||||||
|
vendor: 'Synology',
|
||||||
|
model: 'DS920+',
|
||||||
|
discovered_at: new Date(Date.now() - 120_000).toISOString(),
|
||||||
|
canvas_count: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tour-dev-2',
|
||||||
|
ip: '192.168.1.30',
|
||||||
|
mac: 'AA:BB:CC:DD:EE:30',
|
||||||
|
hostname: 'pi-hole',
|
||||||
|
os: null,
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'Pi-hole', category: 'network' }],
|
||||||
|
suggested_type: 'generic',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
friendly_name: 'Pi-hole',
|
||||||
|
vendor: 'Raspberry Pi',
|
||||||
|
model: null,
|
||||||
|
discovered_at: new Date(Date.now() - 90_000).toISOString(),
|
||||||
|
canvas_count: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Declarative Getting Started steps.
|
||||||
|
*
|
||||||
|
* `anchor` is a CSS selector for the element to ring/spotlight (undefined → the
|
||||||
|
* overlay lights any open modal, or centers the card). `action` is a key resolved
|
||||||
|
* against the walkthrough actions context on step enter (opens a modal, injects
|
||||||
|
* demo data, selects nodes, …). `mode: 'full'` marks a backend-only step that is
|
||||||
|
* filtered out of the standalone/public build. `link` renders a link in the card
|
||||||
|
* (used for the closing "questions?" step).
|
||||||
|
*/
|
||||||
|
export type StepPlacement = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'auto'
|
||||||
|
|
||||||
|
export interface TourStep {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
anchor?: string
|
||||||
|
placement?: StepPlacement
|
||||||
|
action?: string
|
||||||
|
mode?: 'full' | 'all'
|
||||||
|
link?: { label: string; href: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STEPS: TourStep[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
title: 'Welcome to Homelable',
|
||||||
|
body: 'Take a quick tour of the main features — scanning your network, managing devices, and building your canvas. You can skip anytime and restart later from Settings.',
|
||||||
|
placement: 'center',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scan',
|
||||||
|
title: 'Scan your network',
|
||||||
|
body: 'Start here: run an IP scan to auto-discover devices on your network. Pick the ranges and ports, then launch — discovered devices land in your inventory for review.',
|
||||||
|
anchor: '[data-tour="scan-network"]',
|
||||||
|
placement: 'right',
|
||||||
|
action: 'openScanConfig',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scan-history',
|
||||||
|
title: 'Follow scan progress',
|
||||||
|
body: 'Scan History shows every run live. A running scan updates in place with elapsed time and device count — you can stop it here too. (This is example data.)',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'openScanHistoryDemo',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
title: 'Review discovered devices',
|
||||||
|
body: 'Discovered devices wait in your inventory. For each one you can approve it onto the canvas, hide it, or delete it — with detected services and vendor shown. (Example devices.)',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'openInventoryDemo',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nodes',
|
||||||
|
title: 'Your devices on the canvas',
|
||||||
|
body: 'Approved devices become nodes on the canvas. Each shows live status, IP, hostname and running services. Drag to arrange them and draw links between them.',
|
||||||
|
anchor: '.react-flow__node',
|
||||||
|
placement: 'auto',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node-edit',
|
||||||
|
title: 'Edit a device',
|
||||||
|
body: 'Double-click any node to edit it: name, type, IP, the status-check method, appearance, and its parent (e.g. a VM inside a Proxmox host). Changes are yours until you Save.',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'editFirstNode',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'text-zone',
|
||||||
|
title: 'Annotate with text & zones',
|
||||||
|
body: 'Add free Text labels and Zones (colored rectangles) to document your layout — group a rack, mark a VLAN, or leave a note. Both live under the canvas actions.',
|
||||||
|
anchor: '[data-tour="add-zone"]',
|
||||||
|
placement: 'right',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grouping',
|
||||||
|
title: 'Group related devices',
|
||||||
|
body: 'Select multiple nodes, then Create Group to box them together and drag them as one — perfect for a rack, a site, or a subnet.',
|
||||||
|
anchor: '[data-tour="create-group"]',
|
||||||
|
placement: 'left',
|
||||||
|
action: 'selectTwoNodes',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'style',
|
||||||
|
title: 'Make it yours',
|
||||||
|
body: 'Switch the canvas theme from Style, or fine-tune colors, borders and fonts per node type. Your homelab, your look.',
|
||||||
|
anchor: '[data-tour="style"]',
|
||||||
|
placement: 'bottom',
|
||||||
|
action: 'openStyle',
|
||||||
|
mode: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'imports',
|
||||||
|
title: 'Import from your stack',
|
||||||
|
body: 'Already running Zigbee2MQTT, Z-Wave JS, or Proxmox? Import devices directly from them instead of scanning — one click brings the whole topology in.',
|
||||||
|
anchor: '[data-tour="zigbee-import"]',
|
||||||
|
placement: 'right',
|
||||||
|
action: 'openZigbeeImport',
|
||||||
|
mode: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'end',
|
||||||
|
title: "You're all set",
|
||||||
|
body: "That's the tour! Build your map, save it, and share a read-only view. In full mode you also get network scanning, a device inventory, and Zigbee / Z-Wave / Proxmox import. Have a question or an idea?",
|
||||||
|
placement: 'center',
|
||||||
|
mode: 'all',
|
||||||
|
link: { label: 'Ask on GitHub', href: 'https://github.com/Pouzor/homelable/issues' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Steps for the current build — backend-only steps are dropped in standalone. */
|
||||||
|
export function getSteps(standalone: boolean): TourStep[] {
|
||||||
|
return STEPS.filter((s) => s.mode !== 'full' || !standalone)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Getting Started walkthrough animations. Kept in a shared stylesheet (imported
|
||||||
|
by both the invite bubble and the overlay) so keyframes exist regardless of
|
||||||
|
which component is mounted. */
|
||||||
|
|
||||||
|
@keyframes wt-invite-enter {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.walkthrough-invite-enter { animation: wt-invite-enter 0.25s ease-out; }
|
||||||
|
|
||||||
|
@keyframes wt-card-enter {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.walkthrough-card-enter { animation: wt-card-enter 0.2s ease-out; }
|
||||||
|
|
||||||
|
/* The dim surround is drawn by the overlay's SVG mask; this is just the pulsing
|
||||||
|
cyan ring around the primary target (outline so it sits outside the border). */
|
||||||
|
.walkthrough-spotlight--pulse { animation: wt-pulse 1.8s ease-in-out infinite; }
|
||||||
|
@keyframes wt-pulse {
|
||||||
|
0%, 100% { outline: 0px solid rgba(0, 212, 255, 0.55); }
|
||||||
|
50% { outline: 7px solid rgba(0, 212, 255, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.walkthrough-invite-enter,
|
||||||
|
.walkthrough-card-enter,
|
||||||
|
.walkthrough-spotlight--pulse { animation: none; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user