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:
Pouzor
2026-07-19 00:40:08 +02:00
parent bf8551015c
commit 8c56921375
20 changed files with 1004 additions and 14 deletions
@@ -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')
})
})