diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index 68805682..13c0483d 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -33,6 +33,7 @@ import { LazyMotionProvider } from "@/components/motion/lazy-motion-provider";
import { UpdateBanner } from "@/components/update-banner";
import { CommandPalette, triggerCommandPalette } from "@/components/command-palette";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
+import { ProductTour } from "@/components/onboarding/product-tour";
import { useEnvironmentStore } from "@/stores/environment-store";
import { DemoBanner } from "@/components/dashboard/demo-banner";
import { isDemoMode } from "@/lib/is-demo-mode";
@@ -311,6 +312,7 @@ export default function DashboardLayout({
+
diff --git a/src/components/onboarding/__tests__/product-tour.test.tsx b/src/components/onboarding/__tests__/product-tour.test.tsx
new file mode 100644
index 00000000..5c4780c6
--- /dev/null
+++ b/src/components/onboarding/__tests__/product-tour.test.tsx
@@ -0,0 +1,113 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
+import { ProductTour, startProductTour, type TourStep } from "../product-tour";
+
+const DISMISS_KEY = "vf:tour-dismissed";
+
+const STEPS: TourStep[] = [
+ { title: "Pipelines step", body: "Build pipelines here.", targetSelector: '[data-tour="pipelines"]' },
+ { title: "Fleet step", body: "Manage your fleet.", targetSelector: '[data-tour="fleet"]' },
+ { title: "Ghost step", body: "This target is absent.", targetSelector: '[data-tour="does-not-exist"]' },
+];
+
+/** Mount two of the three step targets so the tour can anchor (and auto-start). */
+function mountTargets() {
+ for (const key of ["pipelines", "fleet"]) {
+ const el = document.createElement("a");
+ el.setAttribute("data-tour", key);
+ el.setAttribute("href", `/${key}`);
+ document.body.appendChild(el);
+ }
+}
+
+describe("ProductTour", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ mountTargets();
+ });
+
+ afterEach(() => {
+ cleanup();
+ document.body.innerHTML = "";
+ });
+
+ it("auto-starts on first run and renders step one's title and body", () => {
+ render();
+ expect(screen.getByText("Pipelines step")).toBeDefined();
+ expect(screen.getByText("Build pipelines here.")).toBeDefined();
+ // SR-only progress counter reflects the current step.
+ expect(screen.getByText("Step 1 of 3")).toBeDefined();
+ expect(screen.getByRole("dialog")).toBeDefined();
+ });
+
+ it("Next advances to the following step and Back returns", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ expect(screen.getByText("Fleet step")).toBeDefined();
+ expect(screen.queryByText("Pipelines step")).toBeNull();
+ expect(screen.getByText("Step 2 of 3")).toBeDefined();
+
+ fireEvent.click(screen.getByRole("button", { name: "Back" }));
+ expect(screen.getByText("Pipelines step")).toBeDefined();
+ expect(screen.getByText("Step 1 of 3")).toBeDefined();
+ });
+
+ it("Skip persists the dismissal flag and unmounts the tour", () => {
+ const { container } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Skip tour" }));
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ expect(window.localStorage.getItem(DISMISS_KEY)).toBe("1");
+ });
+
+ it("Finish on the last step persists the flag and unmounts", () => {
+ const { container } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Next" })); // → step 2
+ fireEvent.click(screen.getByRole("button", { name: "Next" })); // → step 3 (last)
+ expect(screen.getByText("Ghost step")).toBeDefined();
+
+ fireEvent.click(screen.getByRole("button", { name: "Finish" }));
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ expect(window.localStorage.getItem(DISMISS_KEY)).toBe("1");
+ });
+
+ it("Escape dismisses the tour and persists the flag", () => {
+ const { container } = render();
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ expect(window.localStorage.getItem(DISMISS_KEY)).toBe("1");
+ });
+
+ it("does not auto-start once dismissed, but startProductTour reopens it", () => {
+ window.localStorage.setItem(DISMISS_KEY, "1");
+ const { container } = render();
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+
+ // Explicit restart clears the one-time flag and reopens at step one.
+ act(() => startProductTour());
+ expect(screen.getByText("Pipelines step")).toBeDefined();
+ expect(window.localStorage.getItem(DISMISS_KEY)).toBeNull();
+ });
+
+ it("renders safely when a step's target selector matches nothing", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ fireEvent.click(screen.getByRole("button", { name: "Next" }));
+ // The ghost step's target is absent — the card still renders (anchored
+ // safely) rather than crashing or vanishing.
+ expect(screen.getByText("Ghost step")).toBeDefined();
+ expect(screen.getByText("This target is absent.")).toBeDefined();
+ expect(screen.getByRole("dialog")).toBeDefined();
+ });
+
+ it("never auto-starts when none of the step targets exist on the page", () => {
+ document.body.innerHTML = ""; // remove the mounted targets
+ const { container } = render();
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ });
+});
diff --git a/src/components/onboarding/create-demo-pipeline-button.tsx b/src/components/onboarding/create-demo-pipeline-button.tsx
index 877f9d16..4beb65b4 100644
--- a/src/components/onboarding/create-demo-pipeline-button.tsx
+++ b/src/components/onboarding/create-demo-pipeline-button.tsx
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Loader2, Sparkles } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
+import { TOUR_DEMO_PIPELINE_ATTR } from "@/components/onboarding/product-tour";
interface CreateDemoPipelineButtonProps {
/** Environment the demo pipeline is created in. */
@@ -60,6 +61,7 @@ export function CreateDemoPipelineButton({
onClick={handleClick}
disabled={createDemo.isPending || !environmentId}
className={className}
+ data-tour={TOUR_DEMO_PIPELINE_ATTR}
>
{createDemo.isPending ? (
<>
diff --git a/src/components/onboarding/onboarding-checklist.tsx b/src/components/onboarding/onboarding-checklist.tsx
index 6a2f1145..f9cc5d75 100644
--- a/src/components/onboarding/onboarding-checklist.tsx
+++ b/src/components/onboarding/onboarding-checklist.tsx
@@ -21,6 +21,7 @@
import { useSyncExternalStore } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
+import { startProductTour } from "@/components/onboarding/product-tour";
import { cn } from "@/lib/utils";
const DISMISS_KEY = "vf:onboarding-dismissed";
@@ -147,16 +148,26 @@ export function OnboardingChecklist({
{completedCount} of {steps.length} steps complete
- {dismissible ? (
+
- ) : null}
+ {dismissible ? (
+
+ ) : null}
+
diff --git a/src/components/onboarding/product-tour.tsx b/src/components/onboarding/product-tour.tsx
new file mode 100644
index 00000000..b41c5748
--- /dev/null
+++ b/src/components/onboarding/product-tour.tsx
@@ -0,0 +1,299 @@
+"use client";
+
+/**
+ * Lightweight, dependency-free first-run product tour.
+ *
+ * Walks a brand-new user through the core surfaces (pipelines, the demo-pipeline
+ * action, the agent fleet, alerts) with a small coachmark card anchored next to
+ * real on-screen elements via `getBoundingClientRect`. Deliberately self-contained
+ * — no tour library, no Radix portal/observer machinery — so it stays trivially
+ * testable in jsdom and adds zero dependencies.
+ *
+ * Behaviour:
+ * - Auto-starts once per browser; completion/skip is persisted in localStorage
+ * (`vf:tour-dismissed`, matching the onboarding-checklist convention) so it
+ * never nags twice.
+ * - Re-startable from anywhere via `startProductTour()` (the onboarding
+ * checklist's "Take a tour" action), which clears the dismissal first.
+ * - Each step targets a CSS selector; a missing target is handled gracefully
+ * (the card anchors to a safe centered position rather than crashing), and
+ * auto-start is suppressed entirely when none of the targets exist.
+ * - Accessible: a focusable role="dialog" card, Esc to dismiss, labelled
+ * controls, and a screen-reader step counter.
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export interface TourStep {
+ /** Short heading shown at the top of the coachmark. */
+ title: string;
+ /** One- or two-sentence explanation of the surface. */
+ body: string;
+ /** CSS selector for the on-screen element this step points at. */
+ targetSelector: string;
+}
+
+const TOUR_DISMISSED_KEY = "vf:tour-dismissed";
+const TOUR_START_EVENT = "vf:tour-start";
+
+/** Selector marking the dashboard's "Create a demo pipeline" action. */
+export const TOUR_DEMO_PIPELINE_ATTR = "create-demo-pipeline";
+
+/**
+ * The default ~4-step tour of VectorFlow's core surfaces. Steps anchor to the
+ * persistent sidebar nav (always present on dashboard routes) plus the
+ * empty-dashboard demo action (gracefully skipped when absent).
+ */
+export const DEFAULT_TOUR_STEPS: TourStep[] = [
+ {
+ title: "Build your pipelines",
+ body: "Wire sources → transforms → sinks on the canvas. Every pipeline you create lives under Pipelines.",
+ targetSelector: 'a[href="/pipelines"]',
+ },
+ {
+ title: "Start from a demo",
+ body: "No agent yet? Spin up a complete sample pipeline in one click and explore the editor right away.",
+ targetSelector: `[data-tour="${TOUR_DEMO_PIPELINE_ATTR}"]`,
+ },
+ {
+ title: "Manage your fleet",
+ body: "Enroll vf-agents and watch their health, throughput, and assigned pipelines from Fleet.",
+ targetSelector: 'a[href="/fleet"]',
+ },
+ {
+ title: "Stay ahead with alerts",
+ body: "Define alert rules on throughput and errors so you hear about problems before your users do.",
+ targetSelector: 'a[href="/alerts"]',
+ },
+];
+
+function readDismissed(): boolean {
+ try {
+ return typeof window !== "undefined" && window.localStorage?.getItem(TOUR_DISMISSED_KEY) === "1";
+ } catch {
+ // Storage unavailable (private mode / disabled) — treat as not dismissed.
+ return false;
+ }
+}
+
+function hasAnyTarget(steps: TourStep[]): boolean {
+ if (typeof document === "undefined") return false;
+ return steps.some((step) => document.querySelector(step.targetSelector) !== null);
+}
+
+/**
+ * (Re)start the product tour from anywhere on the client (e.g. the onboarding
+ * checklist's "Take a tour" action). Clears the one-time dismissal so an
+ * explicit restart always reopens the tour at step one.
+ */
+export function startProductTour(): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage?.removeItem(TOUR_DISMISSED_KEY);
+ } catch {
+ // Ignore — the start event below still fires.
+ }
+ window.dispatchEvent(new Event(TOUR_START_EVENT));
+}
+
+interface TargetRect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+interface Coords {
+ top: number;
+ left: number;
+ /** The anchored target's rect when found — drives the highlight ring. */
+ rect: TargetRect | null;
+}
+
+const CARD_WIDTH = 320;
+const CARD_OFFSET = 12;
+const CARD_EST_HEIGHT = 180;
+
+/** Position the card beside the target, clamped to the viewport. */
+function computeCoords(target: Element | null): Coords {
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
+
+ // Missing target (absent selector or not-yet-mounted): anchor safely so the
+ // step content is still reachable instead of crashing or vanishing.
+ if (!target) {
+ return {
+ top: Math.max(CARD_OFFSET, Math.round(vh * 0.18)),
+ left: Math.max(CARD_OFFSET, Math.round((vw - CARD_WIDTH) / 2)),
+ rect: null,
+ };
+ }
+
+ const r = target.getBoundingClientRect();
+ // Prefer the target's right edge (sidebar nav); flip to the left if it would
+ // overflow, then fall back to centered.
+ let left = r.right + CARD_OFFSET;
+ if (left + CARD_WIDTH > vw - CARD_OFFSET) left = r.left - CARD_WIDTH - CARD_OFFSET;
+ if (left < CARD_OFFSET) left = Math.max(CARD_OFFSET, Math.round((vw - CARD_WIDTH) / 2));
+
+ let top = r.top;
+ if (top + CARD_EST_HEIGHT > vh - CARD_OFFSET) top = vh - CARD_EST_HEIGHT - CARD_OFFSET;
+ if (top < CARD_OFFSET) top = CARD_OFFSET;
+
+ return {
+ top,
+ left,
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height },
+ };
+}
+
+export interface ProductTourProps {
+ /** Steps to walk through. Defaults to the core-surfaces tour. */
+ steps?: TourStep[];
+ /** Auto-open once per browser when not yet dismissed. Defaults to true. */
+ autoStart?: boolean;
+}
+
+export function ProductTour({ steps = DEFAULT_TOUR_STEPS, autoStart = true }: ProductTourProps) {
+ const [active, setActive] = useState(false);
+ const [index, setIndex] = useState(0);
+ const [coords, setCoords] = useState({ top: 0, left: 0, rect: null });
+ const cardRef = useRef(null);
+
+ const step = steps[index];
+ const isFirst = index === 0;
+ const isLast = index === steps.length - 1;
+
+ const close = useCallback((persist: boolean) => {
+ if (persist) {
+ try {
+ window.localStorage?.setItem(TOUR_DISMISSED_KEY, "1");
+ } catch {
+ // Storage write blocked — dismissal is best-effort for this session.
+ }
+ }
+ setActive(false);
+ setIndex(0);
+ }, []);
+
+ // Auto-start once per browser, and always respond to an explicit restart.
+ useEffect(() => {
+ const start = () => {
+ setIndex(0);
+ setActive(true);
+ };
+ if (autoStart && !readDismissed() && hasAnyTarget(steps)) start();
+ window.addEventListener(TOUR_START_EVENT, start);
+ return () => window.removeEventListener(TOUR_START_EVENT, start);
+ }, [autoStart, steps]);
+
+ // Position the card against the current step's target; track resize/scroll.
+ useEffect(() => {
+ if (!active || !step) return;
+ const reposition = () => setCoords(computeCoords(document.querySelector(step.targetSelector)));
+ reposition();
+ window.addEventListener("resize", reposition);
+ window.addEventListener("scroll", reposition, true);
+ return () => {
+ window.removeEventListener("resize", reposition);
+ window.removeEventListener("scroll", reposition, true);
+ };
+ }, [active, step]);
+
+ // Move focus to the card on open and on each step change (announces the new
+ // step to assistive tech, since the card is the labelled dialog).
+ useEffect(() => {
+ if (active) cardRef.current?.focus();
+ }, [active, index]);
+
+ // Esc dismisses the tour (and persists the one-time flag).
+ useEffect(() => {
+ if (!active) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") close(true);
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [active, close]);
+
+ if (!active || !step) return null;
+
+ const titleId = "vf-product-tour-title";
+ const bodyId = "vf-product-tour-body";
+ return (
+ <>
+ {coords.rect ? (
+
+ ) : null}
+
+
+
+ {step.title}
+
+
+
+
+ {step.body}
+
+
+
+
+ {steps.map((_, i) => (
+
+ ))}
+
+
+ Step {index + 1} of {steps.length}
+
+
+ {isFirst ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ >
+ );
+}