feat(issue-17): add onboarding wizard for new users (#26)

* feat(issue-17): add onboarding wizard for new users

Adds a step-based onboarding wizard that guides new users through their
first Claw3D setup: welcome, prerequisites, gateway connection, agent
discovery, and a completion screen.

Architecture:

src/features/onboarding/ (new feature module):
  - types.ts: Step definitions, navigation helpers (getNextStep/getPrevStep)
  - useOnboardingState.ts: localStorage-backed persistence hook
  - index.ts: Barrel exports for clean imports
  - components/OnboardingWizard.tsx: Main wizard container with step
    navigation, progress bar, and modal overlay
  - components/WelcomeStep.tsx: Feature highlights grid
  - components/PrerequisitesStep.tsx: Checklist with links/commands
  - components/ConnectStep.tsx: Compact gateway connection form
  - components/AgentsStep.tsx: Agent discovery feedback
  - components/CompleteStep.tsx: Final screen with CTA

Design decisions:
  - Modular step system: new steps can be added by extending
    OnboardingStepId and registering a component in the switch
  - localStorage persistence: wizard shows once per browser, resettable
    from settings (future: wire into Studio settings API)
  - Connect step gates forward navigation: users cannot skip connection
  - Follows Claw3D conventions: feature-first module, no shared state
    pollution, Tailwind utility classes, lucide-react icons
  - Does NOT modify existing routes or components — zero-risk integration
    (parent wiring left to maintainer preference)

Integration guide (for maintainer):
  1. Import { OnboardingWizard, useOnboardingState } from the module
  2. Add useOnboardingState() to the root layout or agents page
  3. Render <OnboardingWizard /> when showOnboarding is true
  4. Pass gateway connection props from existing store

tests/unit/onboardingTypes.test.ts (13 tests):
  - Step structure validation, navigation helpers, ordering

tests/unit/onboardingState.test.ts (5 tests):
  - localStorage persistence, show/hide/reset lifecycle

Addresses #17

* fix(onboarding): wire wizard launch into office UI

Mount the onboarding wizard in OfficeScreen and add a settings action that can re-open it so the new-user flow is reachable and testable.

Made-with: Cursor

---------

Co-authored-by: Neo <neo@openclaw.ai>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
robotica4us-collab
2026-03-21 17:37:54 -05:00
committed by GitHub
parent ac30f71db0
commit c2cbdeec44
14 changed files with 1001 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useOnboardingState } from "@/features/onboarding/useOnboardingState";
describe("useOnboardingState", () => {
afterEach(() => {
// Clean up localStorage between tests
try {
window.localStorage.removeItem("claw3d:onboarding:completed");
} catch {
// noop
}
});
it("shows onboarding by default when localStorage is empty", () => {
const { result } = renderHook(() => useOnboardingState());
expect(result.current.showOnboarding).toBe(true);
});
it("hides onboarding after completeOnboarding is called", () => {
const { result } = renderHook(() => useOnboardingState());
expect(result.current.showOnboarding).toBe(true);
act(() => {
result.current.completeOnboarding();
});
expect(result.current.showOnboarding).toBe(false);
});
it("persists completion to localStorage", () => {
const { result } = renderHook(() => useOnboardingState());
act(() => {
result.current.completeOnboarding();
});
expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBe(
"true",
);
});
it("reads completion state from localStorage on mount", () => {
window.localStorage.setItem("claw3d:onboarding:completed", "true");
const { result } = renderHook(() => useOnboardingState());
expect(result.current.showOnboarding).toBe(false);
});
it("resets onboarding when resetOnboarding is called", () => {
const { result } = renderHook(() => useOnboardingState());
act(() => {
result.current.completeOnboarding();
});
expect(result.current.showOnboarding).toBe(false);
act(() => {
result.current.resetOnboarding();
});
expect(result.current.showOnboarding).toBe(true);
expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBeNull();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
getNextStep,
getPrevStep,
getStepIndex,
ONBOARDING_STEPS,
} from "@/features/onboarding/types";
describe("ONBOARDING_STEPS", () => {
it("has at least 3 steps", () => {
expect(ONBOARDING_STEPS.length).toBeGreaterThanOrEqual(3);
});
it("starts with welcome and ends with complete", () => {
expect(ONBOARDING_STEPS[0].id).toBe("welcome");
expect(ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id).toBe("complete");
});
it("has unique step IDs", () => {
const ids = ONBOARDING_STEPS.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("every step has a non-empty title and description", () => {
for (const step of ONBOARDING_STEPS) {
expect(step.title.length).toBeGreaterThan(0);
expect(step.description.length).toBeGreaterThan(0);
}
});
});
describe("getStepIndex", () => {
it("returns 0 for welcome", () => {
expect(getStepIndex("welcome")).toBe(0);
});
it("returns last index for complete", () => {
expect(getStepIndex("complete")).toBe(ONBOARDING_STEPS.length - 1);
});
it("returns correct index for connect", () => {
const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
expect(getStepIndex("connect")).toBe(idx);
});
});
describe("getNextStep", () => {
it("returns prerequisites after welcome", () => {
expect(getNextStep("welcome")).toBe("prerequisites");
});
it("returns null after complete", () => {
expect(getNextStep("complete")).toBeNull();
});
it("advances through all steps in order", () => {
let current = ONBOARDING_STEPS[0].id;
const visited: string[] = [current];
let next = getNextStep(current);
while (next) {
visited.push(next);
current = next;
next = getNextStep(current);
}
expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id));
});
});
describe("getPrevStep", () => {
it("returns null for welcome", () => {
expect(getPrevStep("welcome")).toBeNull();
});
it("returns prerequisites for connect", () => {
expect(getPrevStep("connect")).toBe("prerequisites");
});
it("navigates backward through all steps", () => {
let current = ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id;
const visited: string[] = [current];
let prev = getPrevStep(current);
while (prev) {
visited.push(prev);
current = prev;
prev = getPrevStep(current);
}
visited.reverse();
expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id));
});
});