Files
portabase/docs/superpowers/plans/2026-06-17-onboarding-channel-config-style.md
T

42 KiB
Raw Blame History

Onboarding Channel Config + Style Refactor Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a two-phase provider-select → configure flow to the notifier and storage onboarding steps (fake data, stored in flowData), restyle all toggle cards/buttons across onboarding steps to use design-system tokens matching organization-combobox.tsx, and wire conditional step skipping when no agents are created.

Architecture: step-notifier and step-storage manage a local phase state ("grid" | "configuring"). In "configuring" phase they render a <Form> using the existing NotificationChannelFormSchema/StorageChannelFormSchema and renderChannelForm(provider, form). On submit, the configured channel { id, provider, label, name, config } is appended to local state; no server action is called. All toggle <button> elements across steps replace hardcoded border-white/10 with design-system tokens (border-border, border-primary/20, bg-primary/10, hover:bg-accent/50). The agent-create step gets a dynamic nextStep function that skips agent-waiting, project-create, and db-settings when flowData.agents is empty.

Tech Stack: Next.js App Router, @onboardjs/react 1.0.0-rc.5, react-hook-form + zod via useZodForm/Form from @/components/ui/form, existing renderChannelForm from @/features/channel/channels-helpers, NotificationChannelFormSchema/StorageChannelFormSchema from @/features/channel/channel-form.schema, shadcn/radix UI, lucide-react.


File Structure

File Action Responsibility
src/features/onboarding/onboarding.types.ts Modify Extend OnboardingChannel with name + config fields
src/features/onboarding/steps/step-notifier.tsx Rewrite Two-phase grid→config flow for notification providers
src/features/onboarding/steps/step-storage.tsx Rewrite Two-phase grid→config flow for storage providers
src/features/onboarding/steps/step-sso-gate.tsx Modify Restyle SSO provider buttons to org-combobox card style
src/features/onboarding/steps/step-security.tsx Modify Restyle passkey/2FA choice to org-combobox card style
src/features/onboarding/steps/step-preferences.tsx Modify Restyle theme toggle buttons to org-combobox card style
src/features/onboarding/steps/step-project-create.tsx Modify Replace border-white/10 with border-border on DB toggles
src/features/onboarding/onboarding-steps.tsx Modify Change agent-create.nextStep to a conditional function

Task 1: Extend OnboardingChannel type

Files:

  • Modify: src/features/onboarding/onboarding.types.ts

  • Step 1: Update OnboardingChannel

Replace lines 3135 in src/features/onboarding/onboarding.types.ts:

export type OnboardingChannel = {
    id: string;
    provider: string;
    label: string;
    name: string;
    config: Record<string, unknown>;
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: only the existing step-account-info.tsx:28 better-auth error — step-notifier.tsx and step-storage.tsx will error until rewritten in Tasks 2 and 3, which is expected. No errors in onboarding.types.ts itself.

  • Step 3: Commit
git add src/features/onboarding/onboarding.types.ts
git commit -m "feat(onboarding): extend OnboardingChannel with name and config fields"

Task 2: Rewrite step-notifier.tsx — two-phase config flow + style

Files:

  • Rewrite: src/features/onboarding/steps/step-notifier.tsx

Context: This step manages a phase local state. In "grid" phase it shows the provider selection grid. When the user clicks a provider, phase switches to "configuring" with the selected provider ID. In "configuring" phase it renders a full form using NotificationChannelFormSchema and renderChannelForm. On valid submit the channel is added to a local channels list and phase returns to "grid". Clicking Continue saves channels to flowData.notifiers and calls next(). Provider cards use org-combobox design tokens. No server action is called.

  • Step 1: Write the file
// src/features/onboarding/steps/step-notifier.tsx
"use client";

import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { ArrowLeft, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
    useZodForm,
    Form,
    FormField,
    FormItem,
    FormLabel,
    FormControl,
    FormMessage,
} from "@/components/ui/form";
import { notificationProviders } from "@/features/channel/channels-notification-helper";
import { renderChannelForm } from "@/features/channel/channels-helpers";
import { NotificationChannelFormSchema } from "@/features/channel/channel-form.schema";
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";

type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };

export const StepNotifier = () => {
    const { next, updateContext, state } = useOnboarding();
    const [phase, setPhase] = useState<Phase>({ kind: "grid" });
    const [channels, setChannels] = useState<OnboardingChannel[]>([]);

    // @ts-expect-error — discriminated union schema, provider set on phase entry
    const form = useZodForm({ schema: NotificationChannelFormSchema });

    const startConfiguring = (provider: string) => {
        // @ts-expect-error — discriminated union
        form.reset({ provider, enabled: true, name: "", config: {} });
        setPhase({ kind: "configuring", provider });
    };

    const removeChannel = (id: string) => {
        setChannels((prev) => prev.filter((c) => c.id !== id));
    };

    const onContinue = async () => {
        await updateContext({ flowData: { ...state?.context.flowData, notifiers: channels } });
        await next();
    };

    if (phase.kind === "configuring") {
        const providerDetails = notificationProviders.find((p) => p.value === phase.provider);
        const Icon = providerDetails?.icon;

        return (
            <div className="flex flex-col gap-4">
                <div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
                    {Icon && (
                        <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center">
                            <Icon className="size-5" />
                        </div>
                    )}
                    <p className="flex-1 text-sm font-medium">
                        Configuring {providerDetails?.label}
                    </p>
                    <Button
                        type="button"
                        variant="ghost"
                        size="sm"
                        onClick={() => setPhase({ kind: "grid" })}
                    >
                        <ArrowLeft className="size-4 mr-1" />
                        Back
                    </Button>
                </div>
                <Form
                    form={form}
                    className="flex flex-col gap-4"
                    onSubmit={async (values: any) => {
                        const details = notificationProviders.find(
                            (p) => p.value === values.provider
                        );
                        setChannels((prev) => [
                            ...prev,
                            {
                                id: crypto.randomUUID(),
                                provider: values.provider,
                                label: details?.label ?? values.provider,
                                name: values.name,
                                config: values.config as Record<string, unknown>,
                            },
                        ]);
                        // @ts-expect-error — discriminated union
                        form.reset({ enabled: true });
                        setPhase({ kind: "grid" });
                    }}
                >
                    <FormField
                        control={form.control}
                        name="name"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>Channel name *</FormLabel>
                                <FormControl>
                                    <Input
                                        {...field}
                                        value={field.value ?? ""}
                                        placeholder={`e.g. ${providerDetails?.label ?? ""} alerts`}
                                    />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />
                    <FormField
                        control={form.control}
                        name="provider"
                        render={({ field }) => (
                            <input type="hidden" {...field} value={field.value || ""} />
                        )}
                    />
                    {renderChannelForm(phase.provider, form)}
                    <Button type="submit">Add channel</Button>
                </Form>
            </div>
        );
    }

    // Phase: grid
    const configuredProviderIds = channels.map((c) => c.provider);
    const availableProviders = notificationProviders.filter((p) => !p.preview);

    return (
        <div className="flex flex-col gap-4">
            <div>
                <h1 className="text-2xl font-semibold">Connect a notifier</h1>
                <p className="text-sm text-muted-foreground mt-1">
                    Optional  get notified about backups, restores and health checks.
                </p>
            </div>

            {channels.length > 0 && (
                <div className="flex flex-col gap-1">
                    {channels.map((ch) => {
                        const details = notificationProviders.find((p) => p.value === ch.provider);
                        const Icon = details?.icon;
                        return (
                            <div
                                key={ch.id}
                                className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
                            >
                                {Icon && (
                                    <div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
                                        <Icon className="size-4" />
                                    </div>
                                )}
                                <span className="flex-1 truncate">
                                    {ch.name}{" "}
                                    <span className="opacity-60">({ch.label})</span>
                                </span>
                                <button
                                    type="button"
                                    onClick={() => removeChannel(ch.id)}
                                    className="opacity-50 hover:opacity-100 transition-opacity"
                                >
                                    <X className="size-4" />
                                </button>
                            </div>
                        );
                    })}
                </div>
            )}

            <div className="grid grid-cols-2 gap-2">
                {availableProviders.map((provider) => {
                    const Icon = provider.icon;
                    const isConfigured = configuredProviderIds.includes(provider.value);
                    return (
                        <button
                            key={provider.value}
                            type="button"
                            onClick={() => startConfiguring(provider.value)}
                            className={`flex items-center gap-2 rounded-lg border p-2 text-sm transition-colors ${
                                isConfigured
                                    ? "border-primary/20 bg-primary/10 text-primary"
                                    : "border-border hover:bg-accent/50 hover:border-primary/20"
                            }`}
                        >
                            <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
                                <Icon className="size-4" />
                            </div>
                            <span className="flex-1 text-left">{provider.label}</span>
                            {isConfigured && (
                                <div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
                                    <Check className="size-3 text-primary-foreground" strokeWidth={3} />
                                </div>
                            )}
                        </button>
                    );
                })}
            </div>

            <Button type="button" onClick={onContinue}>
                Continue
            </Button>
        </div>
    );
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in step-notifier.tsx.

  • Step 3: Commit
git add src/features/onboarding/steps/step-notifier.tsx
git commit -m "feat(onboarding): add two-phase config flow and org-combobox style to notifier step"

Task 3: Rewrite step-storage.tsx — two-phase config flow + style

Files:

  • Rewrite: src/features/onboarding/steps/step-storage.tsx

Context: Identical pattern to Task 2 but for storage providers. Use StorageChannelFormSchema and storageProviders. Filter out p.value === "local" from the grid (local storage has no config form). renderChannelForm("s3", form) etc. renders the correct sub-form from channels-helpers.tsx.

  • Step 1: Write the file
// src/features/onboarding/steps/step-storage.tsx
"use client";

import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { ArrowLeft, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
    useZodForm,
    Form,
    FormField,
    FormItem,
    FormLabel,
    FormControl,
    FormMessage,
} from "@/components/ui/form";
import { storageProviders } from "@/features/channel/channels-storage-helper";
import { renderChannelForm } from "@/features/channel/channels-helpers";
import { StorageChannelFormSchema } from "@/features/channel/channel-form.schema";
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";

type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };

export const StepStorage = () => {
    const { next, updateContext, state } = useOnboarding();
    const [phase, setPhase] = useState<Phase>({ kind: "grid" });
    const [channels, setChannels] = useState<OnboardingChannel[]>([]);

    // @ts-expect-error — discriminated union schema, provider set on phase entry
    const form = useZodForm({ schema: StorageChannelFormSchema });

    const startConfiguring = (provider: string) => {
        // @ts-expect-error — discriminated union
        form.reset({ provider, enabled: true, name: "", config: {} });
        setPhase({ kind: "configuring", provider });
    };

    const removeChannel = (id: string) => {
        setChannels((prev) => prev.filter((c) => c.id !== id));
    };

    const onContinue = async () => {
        await updateContext({ flowData: { ...state?.context.flowData, storages: channels } });
        await next();
    };

    if (phase.kind === "configuring") {
        const providerDetails = storageProviders.find((p) => p.value === phase.provider);
        const Icon = providerDetails?.icon;

        return (
            <div className="flex flex-col gap-4">
                <div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
                    {Icon && (
                        <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center">
                            <Icon className="size-5" />
                        </div>
                    )}
                    <p className="flex-1 text-sm font-medium">
                        Configuring {providerDetails?.label}
                    </p>
                    <Button
                        type="button"
                        variant="ghost"
                        size="sm"
                        onClick={() => setPhase({ kind: "grid" })}
                    >
                        <ArrowLeft className="size-4 mr-1" />
                        Back
                    </Button>
                </div>
                <Form
                    form={form}
                    className="flex flex-col gap-4"
                    onSubmit={async (values: any) => {
                        const details = storageProviders.find(
                            (p) => p.value === values.provider
                        );
                        setChannels((prev) => [
                            ...prev,
                            {
                                id: crypto.randomUUID(),
                                provider: values.provider,
                                label: details?.label ?? values.provider,
                                name: values.name,
                                config: values.config as Record<string, unknown>,
                            },
                        ]);
                        // @ts-expect-error — discriminated union
                        form.reset({ enabled: true });
                        setPhase({ kind: "grid" });
                    }}
                >
                    <FormField
                        control={form.control}
                        name="name"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>Channel name *</FormLabel>
                                <FormControl>
                                    <Input
                                        {...field}
                                        value={field.value ?? ""}
                                        placeholder={`e.g. ${providerDetails?.label ?? ""} backup`}
                                    />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />
                    <FormField
                        control={form.control}
                        name="provider"
                        render={({ field }) => (
                            <input type="hidden" {...field} value={field.value || ""} />
                        )}
                    />
                    {renderChannelForm(phase.provider, form)}
                    <Button type="submit">Add storage</Button>
                </Form>
            </div>
        );
    }

    // Phase: grid — filter "local" (no config form, no credentials)
    const availableProviders = storageProviders.filter(
        (p) => !p.preview && p.value !== "local"
    );
    const configuredProviderIds = channels.map((c) => c.provider);

    return (
        <div className="flex flex-col gap-4">
            <div>
                <h1 className="text-2xl font-semibold">Connect a storage</h1>
                <p className="text-sm text-muted-foreground mt-1">
                    Optional  choose where backups and files are stored.
                </p>
            </div>

            {channels.length > 0 && (
                <div className="flex flex-col gap-1">
                    {channels.map((ch) => {
                        const details = storageProviders.find((p) => p.value === ch.provider);
                        const Icon = details?.icon;
                        return (
                            <div
                                key={ch.id}
                                className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
                            >
                                {Icon && (
                                    <div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
                                        <Icon className="size-4" />
                                    </div>
                                )}
                                <span className="flex-1 truncate">
                                    {ch.name}{" "}
                                    <span className="opacity-60">({ch.label})</span>
                                </span>
                                <button
                                    type="button"
                                    onClick={() => removeChannel(ch.id)}
                                    className="opacity-50 hover:opacity-100 transition-opacity"
                                >
                                    <X className="size-4" />
                                </button>
                            </div>
                        );
                    })}
                </div>
            )}

            <div className="grid grid-cols-2 gap-2">
                {availableProviders.map((provider) => {
                    const Icon = provider.icon;
                    const isConfigured = configuredProviderIds.includes(provider.value);
                    return (
                        <button
                            key={provider.value}
                            type="button"
                            onClick={() => startConfiguring(provider.value)}
                            className={`flex items-center gap-2 rounded-lg border p-2 text-sm transition-colors ${
                                isConfigured
                                    ? "border-primary/20 bg-primary/10 text-primary"
                                    : "border-border hover:bg-accent/50 hover:border-primary/20"
                            }`}
                        >
                            <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
                                <Icon className="size-4" />
                            </div>
                            <span className="flex-1 text-left">{provider.label}</span>
                            {isConfigured && (
                                <div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
                                    <Check className="size-3 text-primary-foreground" strokeWidth={3} />
                                </div>
                            )}
                        </button>
                    );
                })}
            </div>

            <Button type="button" onClick={onContinue}>
                Continue
            </Button>
        </div>
    );
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in step-storage.tsx.

  • Step 3: Commit
git add src/features/onboarding/steps/step-storage.tsx
git commit -m "feat(onboarding): add two-phase config flow and org-combobox style to storage step"

Task 4: Restyle step-sso-gate.tsx

Files:

  • Modify: src/features/onboarding/steps/step-sso-gate.tsx

Context: SSO provider buttons currently use shadcn Button variant="outline". Replace with org-combobox card style using a Globe icon as placeholder (mockSsoConfig providers have no icon). "Continue with email" stays as a shadcn Button.

  • Step 1: Rewrite the file
// src/features/onboarding/steps/step-sso-gate.tsx
"use client";

import { useOnboarding } from "@onboardjs/react";
import { Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { mockSsoConfig } from "@/features/onboarding/onboarding.mock";

export const StepSsoGate = () => {
    const { next, updateContext, state } = useOnboarding();

    const chooseProvider = async (providerId: string) => {
        await updateContext({ flowData: { ...state?.context.flowData, sso: { providerId } } });
        await next();
    };

    const continueWithEmail = async () => {
        await next();
    };

    return (
        <div className="flex flex-col gap-4">
            <div>
                <h1 className="text-2xl font-semibold">Welcome to Portabase</h1>
                <p className="text-sm text-muted-foreground mt-1">
                    Sign in with your organisation provider, or continue with email.
                </p>
            </div>
            <div className="flex flex-col gap-2">
                {mockSsoConfig.providers.map((provider) => (
                    <button
                        key={provider.id}
                        type="button"
                        onClick={() => chooseProvider(provider.id)}
                        className="flex items-center gap-3 rounded-lg border border-border p-3 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full"
                    >
                        <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
                            <Globe className="size-4 text-muted-foreground" />
                        </div>
                        <span>Continue with {provider.label}</span>
                    </button>
                ))}
            </div>
            {!mockSsoConfig.forced && (
                <Button type="button" onClick={continueWithEmail}>
                    Continue with email
                </Button>
            )}
        </div>
    );
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in step-sso-gate.tsx.

  • Step 3: Commit
git add src/features/onboarding/steps/step-sso-gate.tsx
git commit -m "style(onboarding): restyle sso-gate provider buttons to org-combobox card style"

Task 5: Restyle step-security.tsx

Files:

  • Modify: src/features/onboarding/steps/step-security.tsx

Context: Single-button choice (passkey or two-factor). Replace Button with an org-combobox card. Add a relevant lucide icon (KeyRound for passkey, ShieldCheck for two-factor) inside the icon container.

  • Step 1: Rewrite the file
// src/features/onboarding/steps/step-security.tsx
"use client";

import { useOnboarding } from "@onboardjs/react";
import { KeyRound, ShieldCheck } from "lucide-react";
import { mockSsoConfig } from "@/features/onboarding/onboarding.mock";

export const StepSecurity = () => {
    const { next, updateContext, state } = useOnboarding();

    const choose = async (method: "passkey" | "two-factor") => {
        await updateContext({ flowData: { ...state?.context.flowData, security: { method } } });
        await next();
    };

    return (
        <div className="flex flex-col gap-4">
            <div>
                <h1 className="text-2xl font-semibold">Secure your account</h1>
                <p className="text-sm text-muted-foreground mt-1">
                    {mockSsoConfig.passkeyEnabled
                        ? "Set up a passkey for faster, safer sign-in."
                        : "Set up two-factor authentication to protect your account."}
                </p>
            </div>
            {mockSsoConfig.passkeyEnabled ? (
                <button
                    type="button"
                    onClick={() => choose("passkey")}
                    className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
                >
                    <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
                        <KeyRound className="size-4 text-muted-foreground" />
                    </div>
                    <div className="flex flex-col gap-0.5">
                        <span className="font-medium">Set up passkey</span>
                        <span className="text-xs text-muted-foreground">Faster, safer sign-in</span>
                    </div>
                </button>
            ) : (
                <button
                    type="button"
                    onClick={() => choose("two-factor")}
                    className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
                >
                    <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
                        <ShieldCheck className="size-4 text-muted-foreground" />
                    </div>
                    <div className="flex flex-col gap-0.5">
                        <span className="font-medium">Set up two-factor</span>
                        <span className="text-xs text-muted-foreground">Add an extra layer of security</span>
                    </div>
                </button>
            )}
        </div>
    );
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in step-security.tsx.

  • Step 3: Commit
git add src/features/onboarding/steps/step-security.tsx
git commit -m "style(onboarding): restyle security method choice to org-combobox card style"

Task 6: Restyle step-preferences.tsx

Files:

  • Modify: src/features/onboarding/steps/step-preferences.tsx

Context: Theme toggle currently uses shadcn Button with variant="default"/"outline" switching. Replace with org-combobox toggle cards: active state bg-primary/10 text-primary border-primary/20, inactive border-border hover:bg-accent/50, check badge on active. Sun/Moon icons in icon containers.

  • Step 1: Rewrite the file
// src/features/onboarding/steps/step-preferences.tsx
"use client";

import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Check, Moon, Sun } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

const MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024;

export const StepPreferences = () => {
    const { next, updateContext, state } = useOnboarding();
    const [theme, setTheme] = useState<"light" | "dark">("dark");
    const [avatarDataUrl, setAvatarDataUrl] = useState<string | undefined>(undefined);

    const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if (!file) return;
        if (!file.type.startsWith("image/")) {
            toast.error("Please select an image file.");
            return;
        }
        if (file.size > MAX_AVATAR_SIZE_BYTES) {
            toast.error("Image is too large. Please select a file under 2MB.");
            return;
        }
        const reader = new FileReader();
        reader.onload = () => setAvatarDataUrl(reader.result as string);
        reader.onerror = () => toast.error("Failed to read the selected image. Please try again.");
        reader.readAsDataURL(file);
    };

    const onContinue = async () => {
        await updateContext({ flowData: { ...state?.context.flowData, preferences: { theme, avatarDataUrl } } });
        await next();
    };

    const themeOptions: { value: "light" | "dark"; label: string; Icon: typeof Sun }[] = [
        { value: "light", label: "Light", Icon: Sun },
        { value: "dark", label: "Dark", Icon: Moon },
    ];

    return (
        <div className="flex flex-col gap-4">
            <div>
                <h1 className="text-2xl font-semibold">Make yourself at home</h1>
                <p className="text-sm text-muted-foreground mt-1">Pick your theme and add a profile photo.</p>
            </div>
            <div className="flex items-center gap-4">
                <Avatar className="size-12">
                    <AvatarImage src={avatarDataUrl} alt="" />
                    <AvatarFallback>?</AvatarFallback>
                </Avatar>
                <label className="text-sm underline cursor-pointer">
                    Upload image
                    <input type="file" accept="image/*" className="hidden" onChange={onFileChange} />
                </label>
            </div>
            <div className="flex gap-2">
                {themeOptions.map(({ value, label, Icon }) => {
                    const isActive = theme === value;
                    return (
                        <button
                            key={value}
                            type="button"
                            onClick={() => setTheme(value)}
                            className={`flex items-center gap-2 rounded-lg border p-3 text-sm transition-colors flex-1 ${
                                isActive
                                    ? "border-primary/20 bg-primary/10 text-primary"
                                    : "border-border hover:bg-accent/50 hover:border-primary/20"
                            }`}
                        >
                            <div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
                                <Icon className="size-4" />
                            </div>
                            <span className="flex-1 text-left">{label}</span>
                            {isActive && (
                                <div className="size-5 rounded-full bg-primary flex items-center justify-center">
                                    <Check className="size-3 text-primary-foreground" strokeWidth={3} />
                                </div>
                            )}
                        </button>
                    );
                })}
            </div>
            <Button type="button" onClick={onContinue}>
                Continue
            </Button>
        </div>
    );
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in step-preferences.tsx.

  • Step 3: Commit
git add src/features/onboarding/steps/step-preferences.tsx
git commit -m "style(onboarding): restyle theme toggles in preferences step to org-combobox card style"

Task 7: Restyle step-project-create.tsx

Files:

  • Modify: src/features/onboarding/steps/step-project-create.tsx

Context: DB toggle buttons have hardcoded border-white/10 for inactive state. Replace with design-system tokens. Add icon container and check badge for active state (use Database icon from lucide).

  • Step 1: Rewrite the file
// src/features/onboarding/steps/step-project-create.tsx
"use client";

import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Check, Database } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { mockDatabases } from "@/features/onboarding/onboarding.mock";

export const StepProjectCreate = () => {
    const { next, updateContext, state } = useOnboarding();
    const [name, setName] = useState("");
    const [description, setDescription] = useState("");
    const [databaseIds, setDatabaseIds] = useState<string[]>([]);

    const toggleDb = (id: string) => {
        setDatabaseIds((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]));
    };

    const onContinue = async () => {
        await updateContext({ flowData: { ...state?.context.flowData, project: { name, description, databaseIds } } });
        await next();
    };

    return (
        <div className="flex flex-col gap-4">
            <div>
                <h1 className="text-2xl font-semibold">Create a project</h1>
                <p className="text-sm text-muted-foreground mt-1">Optional  group databases under a project.</p>
            </div>
            <div className="flex flex-col gap-2">
                <Label htmlFor="project-name">Project name</Label>
                <Input id="project-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="My project" />
            </div>
            <div className="flex flex-col gap-2">
                <Label htmlFor="project-description">Description</Label>
                <Textarea id="project-description" value={description} onChange={(e) => setDescription(e.target.value)} />
            </div>
            <div className="flex flex-col gap-2">
                <Label>Databases</Label>
                <div className="flex flex-col gap-2">
                    {mockDatabases.map((db) => {
                        const isSelected = databaseIds.includes(db.id);
                        return (
                            <button
                                key={db.id}
                                type="button"
                                onClick={() => toggleDb(db.id)}
                                className={`flex items-center gap-3 rounded-lg border p-3 text-sm transition-colors text-left ${
                                    isSelected
                                        ? "border-primary/20 bg-primary/10 text-primary"
                                        : "border-border hover:bg-accent/50 hover:border-primary/20"
                                }`}
                            >
                                <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
                                    <Database className="size-4 text-muted-foreground" />
                                </div>
                                <div className="flex flex-col gap-0.5 flex-1">
                                    <span className="font-medium">{db.name}</span>
                                    <span className="text-xs text-muted-foreground">{db.engine}</span>
                                </div>
                                {isSelected && (
                                    <div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
                                        <Check className="size-3 text-primary-foreground" strokeWidth={3} />
                                    </div>
                                )}
                            </button>
                        );
                    })}
                </div>
            </div>
            <Button type="button" onClick={onContinue} disabled={!name.trim()}>
                Continue
            </Button>
        </div>
    );
};
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in step-project-create.tsx.

  • Step 3: Commit
git add src/features/onboarding/steps/step-project-create.tsx
git commit -m "style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style"

Task 8: Update onboarding-steps.tsx — conditional agent-create nextStep

Files:

  • Modify: src/features/onboarding/onboarding-steps.tsx

Context: Change agent-create.nextStep from the static string "agent-waiting" to a function that checks ctx.flowData.agents. If agents were added → "agent-waiting" (normal flow). If none → "finish" (skips agent-waiting, project-create, db-settings). The ctx argument is the OnboardJS context object; its flowData property is { [key: string]: any }.

  • Step 1: Update the agent-create entry in onboarding-steps.tsx

Replace lines 8287:

    {
        id: "agent-create",
        component: StepAgentCreate,
        isSkippable: true,
        skipToStep: undefined,
        nextStep: (ctx) => {
            const agents = ctx.flowData?.agents as unknown[] | undefined;
            return agents && agents.length > 0 ? "agent-waiting" : "finish";
        },
    },
  • Step 2: Verify type-check

Run: pnpm exec tsc --noEmit Expected: no errors in onboarding-steps.tsx. If TypeScript complains about the function signature for nextStep, check the OnboardingStep type in node_modules/@onboardjs/react/dist/**/*.d.ts and cast ctx as needed (e.g. (ctx: any) => ...).

  • Step 3: Commit
git add src/features/onboarding/onboarding-steps.tsx
git commit -m "feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created"

Task 9: Final verification

Files: None — read-only checks.

  • Step 1: Full type-check

Run: pnpm exec tsc --noEmit 2>&1 | grep "onboarding" Expected: only the pre-existing step-account-info.tsx:28 better-auth error. No new errors in any onboarding file.

  • Step 2: Verify step-invite-members and step-agent-create have no hardcoded colors

Run:

grep -n "white/10\|zinc-\|#[0-9a-f]\{3,6\}" \
  src/features/onboarding/steps/step-invite-members.tsx \
  src/features/onboarding/steps/step-agent-create.tsx

Expected: no output. If any hardcoded color is found, replace with the design-system equivalent (border-white/10border-border).

  • Step 3: Verify the step graph conditional skip

Run: grep -A8 '"agent-create"' src/features/onboarding/onboarding-steps.tsx Expected: output shows nextStep: (ctx) => { function, not a plain string.

  • Step 4: Commit if Step 2 required fixes

Only commit if step-invite-members.tsx or step-agent-create.tsx were modified:

git add src/features/onboarding/steps/step-invite-members.tsx \
        src/features/onboarding/steps/step-agent-create.tsx
git commit -m "style(onboarding): remove hardcoded colors from invite-members and agent-create"