diff --git a/docs/superpowers/plans/2026-06-17-onboarding-channel-config-style.md b/docs/superpowers/plans/2026-06-17-onboarding-channel-config-style.md new file mode 100644 index 00000000..fb3ca34c --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-onboarding-channel-config-style.md @@ -0,0 +1,980 @@ +# 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 `
` 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 ` + + { + 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, + }, + ]); + // @ts-expect-error — discriminated union + form.reset({ enabled: true }); + setPhase({ kind: "grid" }); + }} + > + ( + + Channel name * + + + + + + )} + /> + ( + + )} + /> + {renderChannelForm(phase.provider, form)} + + + + ); + } + + // Phase: grid + const configuredProviderIds = channels.map((c) => c.provider); + const availableProviders = notificationProviders.filter((p) => !p.preview); + + return ( +
+
+

Connect a notifier

+

+ Optional — get notified about backups, restores and health checks. +

+
+ + {channels.length > 0 && ( +
+ {channels.map((ch) => { + const details = notificationProviders.find((p) => p.value === ch.provider); + const Icon = details?.icon; + return ( +
+ {Icon && ( +
+ +
+ )} + + {ch.name}{" "} + ({ch.label}) + + +
+ ); + })} +
+ )} + +
+ {availableProviders.map((provider) => { + const Icon = provider.icon; + const isConfigured = configuredProviderIds.includes(provider.value); + return ( + + ); + })} +
+ + +
+ ); +}; +``` + +- [ ] **Step 2: Verify type-check** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors in `step-notifier.tsx`. + +- [ ] **Step 3: Commit** + +```bash +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** + +```typescript +// 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({ kind: "grid" }); + const [channels, setChannels] = useState([]); + + // @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 ( +
+
+ {Icon && ( +
+ +
+ )} +

+ Configuring {providerDetails?.label} +

+ +
+
{ + 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, + }, + ]); + // @ts-expect-error — discriminated union + form.reset({ enabled: true }); + setPhase({ kind: "grid" }); + }} + > + ( + + Channel name * + + + + + + )} + /> + ( + + )} + /> + {renderChannelForm(phase.provider, form)} + + +
+ ); + } + + // 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 ( +
+
+

Connect a storage

+

+ Optional — choose where backups and files are stored. +

+
+ + {channels.length > 0 && ( +
+ {channels.map((ch) => { + const details = storageProviders.find((p) => p.value === ch.provider); + const Icon = details?.icon; + return ( +
+ {Icon && ( +
+ +
+ )} + + {ch.name}{" "} + ({ch.label}) + + +
+ ); + })} +
+ )} + +
+ {availableProviders.map((provider) => { + const Icon = provider.icon; + const isConfigured = configuredProviderIds.includes(provider.value); + return ( + + ); + })} +
+ + +
+ ); +}; +``` + +- [ ] **Step 2: Verify type-check** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors in `step-storage.tsx`. + +- [ ] **Step 3: Commit** + +```bash +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** + +```typescript +// 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 ( +
+
+

Welcome to Portabase

+

+ Sign in with your organisation provider, or continue with email. +

+
+
+ {mockSsoConfig.providers.map((provider) => ( + + ))} +
+ {!mockSsoConfig.forced && ( + + )} +
+ ); +}; +``` + +- [ ] **Step 2: Verify type-check** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors in `step-sso-gate.tsx`. + +- [ ] **Step 3: Commit** + +```bash +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** + +```typescript +// 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 ( +
+
+

Secure your account

+

+ {mockSsoConfig.passkeyEnabled + ? "Set up a passkey for faster, safer sign-in." + : "Set up two-factor authentication to protect your account."} +

+
+ {mockSsoConfig.passkeyEnabled ? ( + + ) : ( + + )} +
+ ); +}; +``` + +- [ ] **Step 2: Verify type-check** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors in `step-security.tsx`. + +- [ ] **Step 3: Commit** + +```bash +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** + +```typescript +// 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(undefined); + + const onFileChange = (e: React.ChangeEvent) => { + 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 ( +
+
+

Make yourself at home

+

Pick your theme and add a profile photo.

+
+
+ + + ? + + +
+
+ {themeOptions.map(({ value, label, Icon }) => { + const isActive = theme === value; + return ( + + ); + })} +
+ +
+ ); +}; +``` + +- [ ] **Step 2: Verify type-check** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors in `step-preferences.tsx`. + +- [ ] **Step 3: Commit** + +```bash +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** + +```typescript +// 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([]); + + 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 ( +
+
+

Create a project

+

Optional — group databases under a project.

+
+
+ + setName(e.target.value)} placeholder="My project" /> +
+
+ +