From bf8551015c929b905c67d06398d82f95238baf3e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Jul 2026 23:02:44 +0200 Subject: [PATCH 1/6] feat(canvas): distinguish new user from cleared canvas; stop demo on backend error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleared canvas re-showed the demo, and backend errors silently fell back to demo — hiding real outages and forcing users to wipe demo nodes before bulk edits. Now: - backend load reports `initialized` (CanvasState row exists = ever saved) - decideCanvasLoad() picks real | empty | demo; empty is kept empty - backend down/error shows an error banner + toast, never the demo - data-new-user flag reserved as the Getting Started walkthrough hook ha-relevant: yes --- backend/app/api/routes/canvas.py | 3 + backend/app/schemas/canvas.py | 5 + backend/tests/test_canvas.py | 17 +++ frontend/src/App.tsx | 125 +++++++++++++----- .../__tests__/canvasLoadDecision.test.ts | 26 ++++ frontend/src/utils/canvasLoadDecision.ts | 25 ++++ 6 files changed, 169 insertions(+), 32 deletions(-) create mode 100644 frontend/src/utils/__tests__/canvasLoadDecision.test.ts create mode 100644 frontend/src/utils/canvasLoadDecision.ts diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py index 6742ca9..c78e3b2 100644 --- a/backend/app/api/routes/canvas.py +++ b/backend/app/api/routes/canvas.py @@ -37,6 +37,9 @@ async def load_canvas( edges=[EdgeResponse.model_validate(e) for e in edges], viewport=viewport, custom_style=state.custom_style if state else None, + # A CanvasState row exists only after a save (or explicit design create), + # so its presence marks an intentional canvas vs. a never-touched one. + initialized=state is not None, ) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 9a62230..f2b689c 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel): edges: list[EdgeResponse] viewport: dict[str, Any] custom_style: dict[str, Any] | None = None + # True once this design's canvas has ever been persisted (a CanvasState row + # exists). Lets the frontend tell a brand-new user (show demo) apart from one + # who intentionally cleared their canvas (keep it empty). False also for a + # missing/uninitialized design. + initialized: bool = False diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 435fc92..abb51a6 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict): assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1} +async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict): + # A never-saved canvas has no CanvasState row → initialized False. The frontend + # uses this to show the demo canvas only to genuinely new users. + data = (await client.get("/api/v1/canvas", headers=headers)).json() + assert data["initialized"] is False + + +async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict): + # Saving an EMPTY canvas still creates a CanvasState row, so a subsequently + # loaded empty canvas is reported initialized — the user cleared it on purpose + # and must not get the demo re-seeded. + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers) + data = (await client.get("/api/v1/canvas", headers=headers)).json() + assert data["nodes"] == [] + assert data["initialized"] is True + + async def test_load_canvas_requires_auth(client: AsyncClient): res = await client.get("/api/v1/canvas") assert res.status_code == 401 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d262891..5395ae8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,6 +45,7 @@ import { useThemeStore } from '@/stores/themeStore' 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 { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' @@ -74,6 +75,13 @@ export default function App() { const loadedDesignIdRef = useRef(loadedDesignId) useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId]) + // True while the last canvas load failed (backend down/error). Drives the error + // banner and keeps us from masking a real failure with the demo canvas. + const [loadError, setLoadError] = useState(false) + // True when the loaded canvas is the demo (a brand-new user). Reserved as the + // entry signal for the upcoming Getting Started walkthrough. + const [isNewUser, setIsNewUser] = useState(false) + const [themeModalOpen, setThemeModalOpen] = useState(false) const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) @@ -151,55 +159,83 @@ export default function App() { }) const loadCanvasFromApi = useCallback(async (designId?: string) => { + let res try { - const res = await canvasApi.load(designId) - const { nodes: apiNodes, edges: apiEdges } = res.data - if (apiNodes.length > 0) { - const proxmoxContainerMap = new Map( - (apiNodes as ApiNode[]) - .filter((n) => n.type === 'group' || n.container_mode === true) - .map((n) => [n.id, true]) - ) - const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( - (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), - (apiEdges as ApiEdge[]).map(deserializeApiEdge), - ) - const savedTheme = res.data.viewport?.theme_id - if (savedTheme) setTheme(savedTheme) - if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) - const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined - // Clear when the target design has no floor plan, so it doesn't bleed - // across canvases when switching designs. - setFloorMap(savedFloorMap ?? null) - loadCanvas(rfNodes, rfEdges) - } else { - setFloorMap(null) - loadCanvas(demoNodes, demoEdges) - } + res = await canvasApi.load(designId) } catch { + // Backend down / errored. Surface it and STOP — never fall back to the demo + // canvas, which would hide the real error and look like a fresh account. + // Leave the on-screen canvas and provenance untouched so an autosave can't + // clobber real data with an empty canvas. + setLoadError(true) + toast.error('Could not load canvas — backend not responding') + return + } + const { nodes: apiNodes, edges: apiEdges } = res.data + const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true) + if (mode === 'real') { + const proxmoxContainerMap = new Map( + (apiNodes as ApiNode[]) + .filter((n) => n.type === 'group' || n.container_mode === true) + .map((n) => [n.id, true]) + ) + const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( + (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), + (apiEdges as ApiEdge[]).map(deserializeApiEdge), + ) + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined + // Clear when the target design has no floor plan, so it doesn't bleed + // across canvases when switching designs. + setFloorMap(savedFloorMap ?? null) + loadCanvas(rfNodes, rfEdges) + } else if (mode === 'empty') { + // Initialized but no nodes: the user cleared this canvas on purpose — respect + // it and keep it empty instead of re-seeding the demo. + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + setFloorMap(null) + loadCanvas([], []) + } else { + // Brand-new, never-saved canvas: seed the demo. setFloorMap(null) loadCanvas(demoNodes, demoEdges) - } finally { - // Record provenance so autosave writes back under the design just loaded. - setLoadedDesignId(designId ?? null) } + setLoadError(false) + setIsNewUser(isNewUserCanvas(mode)) + // Record provenance so autosave writes back under the design just loaded. + setLoadedDesignId(designId ?? null) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) // Standalone counterpart of loadCanvasFromApi — reads a design's canvas from // localStorage, falling back to the demo canvas when it has never been saved. const loadStandaloneCanvas = useCallback((designId: string) => { const saved = standaloneStorage.loadCanvas(designId) - if (saved && saved.nodes.length > 0) { + // A stored entry (even with zero nodes) means the user has saved this canvas, + // so treat it as initialized and don't re-seed the demo on top of a canvas + // they deliberately cleared. + const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null) + if (mode === 'real' && saved) { if (saved.theme_id) setTheme(saved.theme_id) if (saved.custom_style) setCustomStyle(saved.custom_style) // Floor plans are backend-only; keep the store clear in standalone mode. setFloorMap(null) const migrated = migrateClusterHandles(saved.nodes, saved.edges) loadCanvas(migrated.nodes, migrated.edges) + } else if (mode === 'empty' && saved) { + if (saved.theme_id) setTheme(saved.theme_id) + if (saved.custom_style) setCustomStyle(saved.custom_style) + setFloorMap(null) + loadCanvas([], []) } else { setFloorMap(null) loadCanvas(demoNodes, demoEdges) } + setLoadError(false) + setIsNewUser(isNewUserCanvas(mode)) // Record provenance so autosave writes back under the design just loaded. setLoadedDesignId(designId) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) @@ -231,16 +267,24 @@ export default function App() { await loadCanvasFromApi(targetId) } } catch { - // If API fails (e.g. fresh DB with no designs), fall back to demo data - loadCanvas(demoNodes, demoEdges) + // Backend unreachable/errored — surface it. Do NOT seed the demo: that would + // hide a real outage behind a fake "new account" canvas. + setLoadError(true) + toast.error('Could not reach backend — check the server and retry') } - }, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas]) + }, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId]) // Keep a ref so the auth effect can call the latest loader without listing it // as a dependency (which would re-fire on every design switch). const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas) useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas]) + // Retry a failed load from the error banner. + const handleRetryLoad = useCallback(() => { + setLoadError(false) + void loadDesignsAndCanvasRef.current() + }, []) + // Load designs + canvas on auth (or immediately in standalone mode, which has // no auth gate). useEffect(() => { @@ -825,7 +869,9 @@ export default function App() { return ( -
+ {/* data-new-user marks a first-time (demo) canvas — the hook the upcoming + Getting Started walkthrough keys off. */} +
setAddNodeOpen(true)} onAddGroupRect={() => setAddGroupRectOpen(true)} @@ -840,6 +886,21 @@ export default function App() { onOpenPending={openPendingModal} />
+ {loadError && ( +
+ Backend not responding — the canvas could not be loaded. + +
+ )} { + it('renders real nodes when the canvas has any', () => { + expect(decideCanvasLoad(true, true)).toBe('real') + // hasNodes wins even if the initialized flag is somehow false + expect(decideCanvasLoad(true, false)).toBe('real') + }) + + it('keeps an initialized-but-empty canvas empty (user cleared it)', () => { + expect(decideCanvasLoad(false, true)).toBe('empty') + }) + + it('shows the demo only for a brand-new, uninitialized canvas', () => { + expect(decideCanvasLoad(false, false)).toBe('demo') + }) +}) + +describe('isNewUserCanvas', () => { + it('is true only for the demo mode', () => { + expect(isNewUserCanvas('demo')).toBe(true) + expect(isNewUserCanvas('empty')).toBe(false) + expect(isNewUserCanvas('real')).toBe(false) + }) +}) diff --git a/frontend/src/utils/canvasLoadDecision.ts b/frontend/src/utils/canvasLoadDecision.ts new file mode 100644 index 0000000..498a1c8 --- /dev/null +++ b/frontend/src/utils/canvasLoadDecision.ts @@ -0,0 +1,25 @@ +/** + * Decides what to render when a design's canvas loads. + * + * Three cases must stay distinct (issue: clearing a canvas re-showed the demo): + * - `real` → the canvas has saved nodes; render them. + * - `empty` → the canvas is initialized (ever saved / explicitly created) but + * has no nodes; the user intentionally cleared it — keep it empty. + * - `demo` → brand-new, never-initialized canvas; seed the demo so first-time + * users see an example (and, later, the Getting Started walkthrough). + * + * Backend errors are handled by the caller, NOT here — a failed load must show an + * error and must never fall back to `demo` (that would hide the real failure). + */ +export type CanvasLoadMode = 'real' | 'empty' | 'demo' + +export function decideCanvasLoad(hasNodes: boolean, initialized: boolean): CanvasLoadMode { + if (hasNodes) return 'real' + if (initialized) return 'empty' + return 'demo' +} + +/** A new user is one who lands on the demo canvas — the Getting Started hook. */ +export function isNewUserCanvas(mode: CanvasLoadMode): boolean { + return mode === 'demo' +} From 8c56921375f7baa0b7ee36b2f9866cdef0425867 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Jul 2026 00:40:08 +0200 Subject: [PATCH 2/6] 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 ) : ( - +
+
+ +
+
+

New here? Take the tour

+

+ A 2-minute walkthrough of scanning, devices, and building your canvas. +

+
+
+
+ + +
+ + ) +} diff --git a/frontend/src/walkthrough/WalkthroughOverlay.tsx b/frontend/src/walkthrough/WalkthroughOverlay.tsx new file mode 100644 index 0000000..7d08002 --- /dev/null +++ b/frontend/src/walkthrough/WalkthroughOverlay.tsx @@ -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(null) + const [dialogRect, setDialogRect] = useState(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( +
+ {/* Backdrop: dim everything except the cut-out holes (multi-hole SVG mask so + a ringed button AND an open modal can both stay readable). */} + + + + + {holes.map((r, i) => ( + + ))} + + + + + + {/* Pulsing ring on the primary target */} + {ringRect && ( +
+ )} + + {/* Coach card */} +
+
+

{step.title}

+ +
+

{step.body}

+ + {step.link && ( + + {step.link.label} + + )} + +
+ {/* Progress dots */} +
+ {steps.map((s, i) => ( + + ))} +
+
+ + {stepIndex + 1}/{steps.length} + + + +
+
+
+
, + document.body, + ) +} diff --git a/frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx b/frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx new file mode 100644 index 0000000..752e4f5 --- /dev/null +++ b/frontend/src/walkthrough/__tests__/WalkthroughInvite.test.tsx @@ -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() + 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() + expect(screen.queryByText('New here? Take the tour')).not.toBeInTheDocument() + }) + + it('"Getting started" launches the tour', () => { + renderWithProviders() + 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() + 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() + 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() + }) +}) diff --git a/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx b/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx new file mode 100644 index 0000000..3c21c75 --- /dev/null +++ b/frontend/src/walkthrough/__tests__/WalkthroughOverlay.test.tsx @@ -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() + expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument() + }) + + it('shows the first step and progress when active', () => { + renderWithProviders() + startTour() + expect(screen.getByText('Welcome to Homelable')).toBeInTheDocument() + expect(screen.getByText(`1/${TOTAL}`)).toBeInTheDocument() + }) + + it('advances and rewinds between steps', () => { + renderWithProviders() + 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() + startTour() + fireEvent.click(screen.getByLabelText('Skip walkthrough')) + expect(useWalkthroughStore.getState().isActive).toBe(false) + expect(screen.queryByText('Welcome to Homelable')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/walkthrough/__tests__/steps.test.ts b/frontend/src/walkthrough/__tests__/steps.test.ts new file mode 100644 index 0000000..b654943 --- /dev/null +++ b/frontend/src/walkthrough/__tests__/steps.test.ts @@ -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') + }) +}) diff --git a/frontend/src/walkthrough/actions.ts b/frontend/src/walkthrough/actions.ts new file mode 100644 index 0000000..d110302 --- /dev/null +++ b/frontend/src/walkthrough/actions.ts @@ -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(null) + +export const WalkthroughActionsProvider = WalkthroughActionsContext.Provider + +export function useWalkthroughActions(): WalkthroughActionApi | null { + return useContext(WalkthroughActionsContext) +} diff --git a/frontend/src/walkthrough/demoTourData.ts b/frontend/src/walkthrough/demoTourData.ts new file mode 100644 index 0000000..671f0f6 --- /dev/null +++ b/frontend/src/walkthrough/demoTourData.ts @@ -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, + }, +] diff --git a/frontend/src/walkthrough/steps.ts b/frontend/src/walkthrough/steps.ts new file mode 100644 index 0000000..4b27d51 --- /dev/null +++ b/frontend/src/walkthrough/steps.ts @@ -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) +} diff --git a/frontend/src/walkthrough/walkthrough.css b/frontend/src/walkthrough/walkthrough.css new file mode 100644 index 0000000..448a18f --- /dev/null +++ b/frontend/src/walkthrough/walkthrough.css @@ -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; } +} From a1bf54c41703bce6240e597c6dbe6c43196d0db4 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Jul 2026 01:32:08 +0200 Subject: [PATCH 3/6] fix(walkthrough): keep sidebar crisp behind modal steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neutralize the app modal backdrop's dim/blur while the tour runs so a ringed sidebar/toolbar control behind an open modal (steps 2, 10) stays readable — the overlay's SVG spotlight is now the single source of dimming. Add a walkthrough-running body class toggled by the overlay and a CSS rule stripping [data-slot=dialog-overlay] background/backdrop-filter. Also refine coach-card placement: dialogCardPosition drops the card into the empty bottom-right corner for large modals (inventory, scan history) and beside small modals, so it never covers modal content (steps 3, 4). --- .../src/walkthrough/WalkthroughOverlay.tsx | 33 +++++++++++++++++-- frontend/src/walkthrough/walkthrough.css | 10 ++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/frontend/src/walkthrough/WalkthroughOverlay.tsx b/frontend/src/walkthrough/WalkthroughOverlay.tsx index 7d08002..05e5359 100644 --- a/frontend/src/walkthrough/WalkthroughOverlay.tsx +++ b/frontend/src/walkthrough/WalkthroughOverlay.tsx @@ -36,6 +36,23 @@ function cardPosition(rect: DOMRect | null, placement: StepPlacement): { left: n } } +/** + * Position the card relative to an OPEN MODAL (the step's real subject). + * Large modals (inventory, scan history at ~90vw) leave no room beside them, so + * the card drops into the empty bottom-right corner instead of covering content. + * Small modals get the card beside them, on whichever side has room. + */ +function dialogCardPosition(rect: DOMRect): { left: number; top: number } { + const vw = window.innerWidth + const vh = window.innerHeight + const clampT = (t: number) => Math.max(8, Math.min(t, vh - CARD_H_EST - 8)) + const isLarge = rect.width > vw * 0.6 || rect.height > vh * 0.7 + if (isLarge) return { left: vw - CARD_W - 16, top: vh - CARD_H_EST - 16 } + if (rect.left >= CARD_W + GAP * 2) return { left: rect.left - GAP - CARD_W, top: clampT(rect.top) } + if (vw - rect.right >= CARD_W + GAP * 2) return { left: rect.right + GAP, top: clampT(rect.top) } + return { left: Math.max(8, Math.min(rect.left, vw - CARD_W - 8)), top: clampT(rect.bottom + GAP) } +} + export function WalkthroughOverlay() { const { isActive, stepIndex, total, setTotal, next, prev, skip } = useWalkthroughStore() const actions = useWalkthroughActions() @@ -99,12 +116,24 @@ export function WalkthroughOverlay() { return () => window.removeEventListener('keydown', handleKey) }, [isActive, handleKey]) + // Flag the tour so app-modal backdrops drop their own dim/blur (see + // walkthrough.css) — the spotlight then dims consistently everywhere. + useEffect(() => { + if (!isActive) return + document.body.classList.add('walkthrough-running') + return () => document.body.classList.remove('walkthrough-running') + }, [isActive]) + if (!isActive || !step) return null const isLast = stepIndex >= total - 1 - // Ring / card follow the anchor if the step has one, else the open modal. + // Ring the step's anchor (sidebar/toolbar button) if it has one, else the modal. const ringRect = anchorRect ?? dialogRect - const pos = cardPosition(ringRect, step.placement ?? 'auto') + // The card follows the OPEN MODAL when there is one (that's what the user reads), + // otherwise the step's anchor. Keeps the card off the modal's content. + const pos = dialogRect + ? dialogCardPosition(dialogRect) + : cardPosition(anchorRect, 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) diff --git a/frontend/src/walkthrough/walkthrough.css b/frontend/src/walkthrough/walkthrough.css index 448a18f..7db09fa 100644 --- a/frontend/src/walkthrough/walkthrough.css +++ b/frontend/src/walkthrough/walkthrough.css @@ -22,6 +22,16 @@ 50% { outline: 7px solid rgba(0, 212, 255, 0); } } +/* While the tour runs, app modals must not add their own dim/blur — the overlay's + SVG spotlight is the single source of dimming, so a ringed sidebar/toolbar + control behind an open modal stays crisp instead of being double-dimmed and + blurred by the modal's own backdrop. */ +body.walkthrough-running [data-slot="dialog-overlay"] { + background: transparent !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} + @media (prefers-reduced-motion: reduce) { .walkthrough-invite-enter, .walkthrough-card-enter, From deccff15cf18bf76a4a5e8187bab7b165facbe1a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Jul 2026 11:05:43 +0200 Subject: [PATCH 4/6] changing wording --- frontend/src/walkthrough/steps.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/walkthrough/steps.ts b/frontend/src/walkthrough/steps.ts index 4b27d51..4fc2100 100644 --- a/frontend/src/walkthrough/steps.ts +++ b/frontend/src/walkthrough/steps.ts @@ -25,14 +25,14 @@ 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.', + 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.', + 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', @@ -41,7 +41,7 @@ export const STEPS: TourStep[] = [ { 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.)', + 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', @@ -49,7 +49,7 @@ export const STEPS: TourStep[] = [ { 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.)', + 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', @@ -73,7 +73,7 @@ export const STEPS: TourStep[] = [ { 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.', + body: 'Add free Text labels and Zones (colored rectangles) to document your layout to 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', @@ -81,7 +81,7 @@ export const STEPS: TourStep[] = [ { 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.', + 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', @@ -99,7 +99,7 @@ export const STEPS: TourStep[] = [ { 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.', + 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', From e85dab8cfc9e4378d0435ef1be5c001cdaafd1c7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Jul 2026 11:33:54 +0200 Subject: [PATCH 5/6] fix(toaster): render error toasts on a red surface Error toasts previously used the same neutral popover background as success toasts, so a failure (e.g. a save while the backend is down) read like a normal 'Canvas saved'. Set sonner's per-type --error-bg/ --error-text/--error-border so errors stand out in red. --- .../components/ui/__tests__/sonner.test.tsx | 36 +++++++++++++++++++ frontend/src/components/ui/sonner.tsx | 5 +++ 2 files changed, 41 insertions(+) create mode 100644 frontend/src/components/ui/__tests__/sonner.test.tsx diff --git a/frontend/src/components/ui/__tests__/sonner.test.tsx b/frontend/src/components/ui/__tests__/sonner.test.tsx new file mode 100644 index 0000000..dc23446 --- /dev/null +++ b/frontend/src/components/ui/__tests__/sonner.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest' +import { render, cleanup, waitFor } from '@testing-library/react' +import { toast } from 'sonner' +import { Toaster } from '../sonner' + +beforeAll(() => { + // sonner reads matchMedia for theme detection; jsdom doesn't provide it. + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) +}) + +afterEach(() => cleanup()) + +describe('Toaster', () => { + it('renders error toasts on a red surface so failures are visible', async () => { + render() + toast.error('Save failed') + // The style (with the CSS vars) lands on the toast list, mounted on first toast. + const list = await waitFor(() => { + const el = document.querySelector('[data-sonner-toaster]') as HTMLElement | null + expect(el).not.toBeNull() + return el! + }) + const style = list.getAttribute('style') ?? '' + expect(style).toContain('--error-bg: #f85149') + expect(style).toContain('--error-text: #ffffff') + }) +}) diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 9280ee5..12ab15f 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -34,6 +34,11 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", + // Errors use a red surface so failures (e.g. a failed save while the + // backend is down) stand out instead of reading like a normal toast. + "--error-bg": "#f85149", + "--error-text": "#ffffff", + "--error-border": "#da3633", } as React.CSSProperties } toastOptions={{ From 5726289b181d47afe9375c6976c9b56396b8802b Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Jul 2026 11:44:47 +0200 Subject: [PATCH 6/6] fix(toaster): enable richColors so error surface actually applies Setting --error-bg alone had no effect: sonner only honors per-type surfaces when richColors is enabled, so error toasts kept using --normal-bg and looked identical to a normal toast. Enable richColors and pin success/info/warning back to the neutral popover surface so only errors turn red. --- .../src/components/ui/__tests__/sonner.test.tsx | 10 ++++++++++ frontend/src/components/ui/sonner.tsx | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/__tests__/sonner.test.tsx b/frontend/src/components/ui/__tests__/sonner.test.tsx index dc23446..269f6ae 100644 --- a/frontend/src/components/ui/__tests__/sonner.test.tsx +++ b/frontend/src/components/ui/__tests__/sonner.test.tsx @@ -32,5 +32,15 @@ describe('Toaster', () => { const style = list.getAttribute('style') ?? '' expect(style).toContain('--error-bg: #f85149') expect(style).toContain('--error-text: #ffffff') + + // richColors must be on for sonner to apply the per-type surface, and the + // toast itself must be tagged as an error so it picks up --error-bg. + const item = await waitFor(() => { + const el = document.querySelector('[data-sonner-toast]') as HTMLElement | null + expect(el).not.toBeNull() + return el! + }) + expect(item.getAttribute('data-rich-colors')).toBe('true') + expect(item.getAttribute('data-type')).toBe('error') }) }) diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 12ab15f..aae6177 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -11,6 +11,7 @@ const Toaster = ({ ...props }: ToasterProps) => { @@ -34,11 +35,23 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", "--border-radius": "var(--radius)", - // Errors use a red surface so failures (e.g. a failed save while the - // backend is down) stand out instead of reading like a normal toast. + // richColors (below) is required for sonner to honor per-type surfaces. + // Errors get a red surface so failures (e.g. a save while the backend + // is down) stand out instead of reading like a normal toast... "--error-bg": "#f85149", "--error-text": "#ffffff", "--error-border": "#da3633", + // ...while success/info/warning stay on the neutral popover surface so + // the rest of the toast UX is unchanged. + "--success-bg": "var(--popover)", + "--success-text": "var(--popover-foreground)", + "--success-border": "var(--border)", + "--info-bg": "var(--popover)", + "--info-text": "var(--popover-foreground)", + "--info-border": "var(--border)", + "--warning-bg": "var(--popover)", + "--warning-text": "var(--popover-foreground)", + "--warning-border": "var(--border)", } as React.CSSProperties } toastOptions={{