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,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')
})
})
+30
View File
@@ -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)
}
+65
View File
@@ -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,
},
]
+121
View File
@@ -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)
}
+29
View File
@@ -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; }
}