From a5fcda05aae93e48d01f53dbc7cd6f3a0780e452 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sun, 30 Aug 2026 08:56:09 +0100 Subject: [PATCH] feat(onboarding): extend V3 workshop preview Signed-off-by: kenny lopez --- .../onboarding/onboardingPreview.test.mjs | 36 +- .../features/onboarding/onboardingPreview.ts | 58 +- .../src/features/onboarding/ui/BackupStep.tsx | 32 +- .../onboarding/ui/DownloadKeyStep.tsx | 8 +- .../onboarding/ui/EncryptedBackupCreator.tsx | 5 +- .../onboarding/ui/HarnessConnectionStep.tsx | 652 ++++++++++++++++ .../onboarding/ui/IdentityKeyHelpDialog.tsx | 9 +- .../onboarding/ui/OnboardingPreviewApp.tsx | 437 +++-------- .../ui/OnboardingPreviewControls.tsx | 21 + .../ui/OnboardingPreviewCredentials.tsx | 303 ++++++++ .../ui/OnboardingPreviewJourney.tsx | 5 +- .../ui/WelcomeChannelAppPreview.tsx | 715 ++++++++++-------- desktop/src/shared/ui/segmented-control.tsx | 75 +- 13 files changed, 1680 insertions(+), 676 deletions(-) create mode 100644 desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx create mode 100644 desktop/src/features/onboarding/ui/OnboardingPreviewCredentials.tsx diff --git a/desktop/src/features/onboarding/onboardingPreview.test.mjs b/desktop/src/features/onboarding/onboardingPreview.test.mjs index 7d1558d0d3..b2b35dd59f 100644 --- a/desktop/src/features/onboarding/onboardingPreview.test.mjs +++ b/desktop/src/features/onboarding/onboardingPreview.test.mjs @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + ONBOARDING_PREVIEW_LANDING_ACTIONS, ONBOARDING_PREVIEW_JOURNEYS, + resolveOnboardingPreviewJourney, resolveOnboardingPreviewMode, } from "./onboardingPreview.ts"; @@ -48,6 +50,16 @@ test("preview is available to the explicit E2E build", () => { }); test("today preserves the complete workshop journey", () => { + assert.deepEqual(ONBOARDING_PREVIEW_LANDING_ACTIONS.today, { + primary: { + label: "Create a new identity key", + page: "identity-key", + }, + secondary: { + label: "Use an existing key", + page: "sign-in-key", + }, + }); assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.today, { afterAccount: "setup", afterCommunityEntry: "community-connecting", @@ -61,8 +73,26 @@ test("today preserves the complete workshop journey", () => { }); }); -test("V3 skips technical setup and the transitional community screens", () => { +test("V3 uses the focused harness step and skips transitional community screens", () => { + assert.deepEqual(ONBOARDING_PREVIEW_LANDING_ACTIONS.v3, { + primary: { label: "Create an account", page: "email" }, + secondary: { label: "Sign in", page: "sign-in" }, + }); assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.v3, { + afterAccount: "harness-connection", + afterCommunityEntry: "community-profile", + afterProfile: "community-home", + communityChoiceBack: "harness-connection", + communityStep: 4, + finalStep: 5, + includeExistingCommunity: false, + profileStep: 5, + totalSteps: 5, + }); +}); + +test("V3 can preview the same journey with harness connection deferred", () => { + assert.deepEqual(resolveOnboardingPreviewJourney("v3", false), { afterAccount: "community-choice", afterCommunityEntry: "community-profile", afterProfile: "community-home", @@ -73,4 +103,8 @@ test("V3 skips technical setup and the transitional community screens", () => { profileStep: 4, totalSteps: 4, }); + assert.equal( + resolveOnboardingPreviewJourney("today", false), + ONBOARDING_PREVIEW_JOURNEYS.today, + ); }); diff --git a/desktop/src/features/onboarding/onboardingPreview.ts b/desktop/src/features/onboarding/onboardingPreview.ts index 4d1770b66e..40d49bee2a 100644 --- a/desktop/src/features/onboarding/onboardingPreview.ts +++ b/desktop/src/features/onboarding/onboardingPreview.ts @@ -18,6 +18,8 @@ export type OnboardingPreviewPage = | "identity-key-help" | "backup-password" | "setup" + | "harness-connection" + | "harness-connection-detail" | "config" | "community-choice" | "community-entry" @@ -29,6 +31,35 @@ export type OnboardingPreviewPage = export type OnboardingPreviewVariant = "today" | "v3"; +export const ONBOARDING_PREVIEW_LANDING_ACTIONS = { + today: { + primary: { + label: "Create a new identity key", + page: "identity-key", + }, + secondary: { + label: "Use an existing key", + page: "sign-in-key", + }, + }, + v3: { + primary: { + label: "Create an account", + page: "email", + }, + secondary: { + label: "Sign in", + page: "sign-in", + }, + }, +} as const satisfies Record< + OnboardingPreviewVariant, + Record< + "primary" | "secondary", + { label: string; page: OnboardingPreviewPage } + > +>; + type OnboardingPreviewJourney = { afterAccount: OnboardingPreviewPage; afterCommunityEntry: OnboardingPreviewPage; @@ -58,17 +89,36 @@ export const ONBOARDING_PREVIEW_JOURNEYS: Record< totalSteps: 7, }, v3: { - afterAccount: "community-choice", + afterAccount: "harness-connection", afterCommunityEntry: "community-profile", afterProfile: "community-home", + communityChoiceBack: "harness-connection", + communityStep: 4, + finalStep: 5, + includeExistingCommunity: false, + profileStep: 5, + totalSteps: 5, + }, +}; + +/** Resolve the optional V3 harness experiment without changing Today's flow. */ +export function resolveOnboardingPreviewJourney( + variant: OnboardingPreviewVariant, + harnessConnectionInOnboarding: boolean, +): OnboardingPreviewJourney { + const journey = ONBOARDING_PREVIEW_JOURNEYS[variant]; + if (variant !== "v3" || harnessConnectionInOnboarding) return journey; + + return { + ...journey, + afterAccount: "community-choice", communityChoiceBack: null, communityStep: 3, finalStep: 4, - includeExistingCommunity: false, profileStep: 4, totalSteps: 4, - }, -}; + }; +} /** Resolve the explicit, non-production onboarding workshop route. */ export function resolveOnboardingPreviewMode({ diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index cd267540c6..03fbf8dab9 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -74,6 +74,8 @@ type BackupStepProps = { previewMode?: boolean; returningFromSecurity: boolean; showPreviewBackupShortcut?: boolean; + /** Apply the experimental V3 copy and controls inside the safe preview. */ + v3Presentation?: boolean; }; /** @@ -94,6 +96,7 @@ export function BackupStep({ previewMode = false, returningFromSecurity, showPreviewBackupShortcut = true, + v3Presentation = false, }: BackupStepProps) { const cardLayout = useOnboardingPreviewCardLayout(); const reduceMotion = useReducedMotion() ?? false; @@ -105,8 +108,8 @@ export function BackupStep({ const [nsec, setNsec] = React.useState(null); const [isRevealed, setIsRevealed] = React.useState(false); const canUseSpoilerParticles = React.useMemo( - () => previewMode && supportsSpoilerParticles(), - [previewMode], + () => v3Presentation && supportsSpoilerParticles(), + [v3Presentation], ); const cancelledRef = React.useRef(false); const copiedTimerRef = React.useRef(null); @@ -224,9 +227,11 @@ export function BackupStep({ )} >

- {previewMode ? "Create a private identity key" : "Backup options"} + {v3Presentation + ? "Create a private identity key" + : "Backup options"}

- {previewMode ? ( + {v3Presentation ? (

This key will be how you log into Buzz. You can use it across @@ -236,7 +241,8 @@ export function BackupStep({

@@ -249,7 +255,7 @@ export function BackupStep({ )} - {previewMode ? ( + {v3Presentation ? (
)} - {previewMode ? ( + {v3Presentation ? (
- {previewMode ? ( + {v3Presentation ? (
) : null} - {!previewMode ? ( + {!v3Presentation ? (

@@ -616,7 +622,7 @@ export function BackupStep({ )} - {previewMode && created && showPreviewBackupShortcut ? ( + {v3Presentation && created && showPreviewBackupShortcut ? ( diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index 294e2dfc78..2319501ef1 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -27,6 +27,8 @@ type DownloadKeyStepProps = { onBack: () => void; /** Use in-memory backup operations inside the dev-only workshop preview. */ previewMode?: boolean; + /** Apply the experimental V3 wording inside the safe preview. */ + v3Presentation?: boolean; }; /** @@ -39,6 +41,7 @@ export function DownloadKeyStep({ session, onBack, previewMode = false, + v3Presentation = false, }: DownloadKeyStepProps) { const cardLayout = useOnboardingPreviewCardLayout(); const reduceMotion = useReducedMotion() ?? false; @@ -48,7 +51,7 @@ export function DownloadKeyStep({ const hasVerifiedBackup = session.verified; const hasSelectedBackup = session.test.stage === "password"; const showPreviewSaved = - previewMode && hasCreated && session.test.stage === "drop"; + v3Presentation && hasCreated && session.test.stage === "drop"; const primaryActionClass = cardLayout ? ONBOARDING_PRIMARY_CTA_CLASS : ONBOARDING_SECURITY_PRIMARY_CTA_CLASS; @@ -157,6 +160,7 @@ export function DownloadKeyStep({ previewMode={previewMode} session={session} variant="spotlight" + v3Presentation={v3Presentation} verifyButtonPortal={primaryActionSlot} />

@@ -205,7 +209,7 @@ export function DownloadKeyStep({ variant="ghost" > {hasVerifiedBackup - ? previewMode + ? v3Presentation ? "Continue" : "Finish" : "Skip for now"} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index fb219dd34a..f0372625cd 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -277,6 +277,8 @@ type EncryptedBackupCreatorProps = { onVerified?: () => void; /** Replace native identity and file operations inside the dev-only preview. */ previewMode?: boolean; + /** Apply the experimental V3 wording inside the safe preview. */ + v3Presentation?: boolean; }; /** @@ -481,6 +483,7 @@ export function EncryptedBackupCreator({ guidedTest = true, onVerified, previewMode = false, + v3Presentation = false, }: EncryptedBackupCreatorProps) { const cardLayout = useOnboardingPreviewCardLayout(); // Hosts without a longer-lived session get a private one (settings card). @@ -889,7 +892,7 @@ export function EncryptedBackupCreator({ ) : state.savedPassword ? ( "Download backup again" - ) : previewMode ? ( + ) : v3Presentation ? ( "Save backup" ) : ( "Backup key" diff --git a/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx b/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx new file mode 100644 index 0000000000..bc7ed2ed67 --- /dev/null +++ b/desktop/src/features/onboarding/ui/HarnessConnectionStep.tsx @@ -0,0 +1,652 @@ +import { ChevronRight } from "lucide-react"; +import * as React from "react"; + +import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; +import { + getProviderApiKeyLabel, + getPersonaModelOptions, + PERSONA_LLM_PROVIDER_OPTIONS, + runtimeSupportsLlmProviderSelection, +} from "@/features/agents/ui/agentConfigOptions"; +import { + BUZZ_AGENT_THINKING_EFFORT_VALUES, + getProviderEffortConfig, +} from "@/features/agents/ui/buzzAgentConfig"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; +import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { OnboardingFooter } from "./OnboardingFooter"; +import { OnboardingPreviewInput } from "./OnboardingPreviewInput"; +import { + OnboardingPreviewStep, + useOnboardingPreviewCardLayout, +} from "./OnboardingPreviewShell"; +import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; +import { getRuntimeDisplayLabel, RuntimeIcon } from "./RuntimeIcon"; +import { ONBOARDING_PREVIEW_CARD_INPUT_CLASS } from "./onboardingPreviewCardStyles"; + +export type HarnessConnectionMethod = "subscription" | "api"; + +export type HarnessConnectionOption = { + methods: readonly HarnessConnectionMethod[]; + runtime: AcpRuntimeCatalogEntry; +}; + +const CONNECTION_METHOD_OPTIONS = [ + { label: "Subscription", value: "subscription" }, + { label: "API", value: "api" }, +] as const; + +function previewRuntime({ + available = false, + id, + label, + source = "preset", +}: { + available?: boolean; + id: string; + label: string; + source?: AcpRuntimeCatalogEntry["source"]; +}): AcpRuntimeCatalogEntry { + return { + authStatus: available + ? { status: "not_applicable" } + : { status: "unknown" }, + availability: available ? "available" : "not_installed", + avatarUrl: "", + binaryPath: available ? `/usr/local/bin/${id}` : null, + canAutoInstall: true, + command: available ? id : null, + contextLimitEnvVar: null, + defaultArgs: [], + id, + installHint: `Install ${label} to connect it to Buzz.`, + installInstructionsUrl: "https://github.com/block/buzz", + label, + loginHint: null, + maxRoundsEnvVar: null, + maxTokensEnvVar: null, + mcpCommand: null, + modelEnvVar: null, + nodeRequired: false, + providerEnvVar: null, + requiresExternalCli: id !== "buzz-agent", + source, + thinkingEnvVar: null, + underlyingCliPath: null, + }; +} + +/** Fixed workshop catalog. Availability changes only in React memory. */ +export const HARNESS_CONNECTION_OPTIONS: readonly HarnessConnectionOption[] = [ + { + methods: ["subscription", "api"], + runtime: previewRuntime({ + available: true, + id: "claude", + label: "Claude Code", + source: "builtin", + }), + }, + { + methods: ["subscription", "api"], + runtime: previewRuntime({ id: "codex", label: "Codex", source: "builtin" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ + available: true, + id: "goose", + label: "Goose", + source: "builtin", + }), + }, + { + methods: ["api"], + runtime: previewRuntime({ + available: true, + id: "buzz-agent", + label: "Buzz Agent", + source: "builtin", + }), + }, + { + methods: ["subscription"], + runtime: previewRuntime({ id: "cursor", label: "Cursor" }), + }, + { + methods: ["subscription"], + runtime: previewRuntime({ id: "devin", label: "Devin" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ id: "omp", label: "Oh My Pi" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ id: "grok", label: "Grok Build" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ id: "opencode", label: "OpenCode" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ id: "kimi", label: "Kimi Code" }), + }, + { + methods: ["subscription"], + runtime: previewRuntime({ id: "amp", label: "Amp" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ id: "hermes", label: "Hermes Agent" }), + }, + { + methods: ["api"], + runtime: previewRuntime({ id: "openclaw", label: "OpenClaw" }), + }, +]; + +/** + * Reusable row list for selecting or installing an AI client. The caller owns + * availability and side effects so onboarding can remain a session-only + * workshop while an in-app surface can wire the same presentation to Tauri. + */ +export function HarnessConnectionList({ + installedIds, + onInstall, + onSelect, + options, +}: { + installedIds: ReadonlySet; + onInstall: (option: HarnessConnectionOption) => void; + onSelect: (option: HarnessConnectionOption) => void; + options: readonly HarnessConnectionOption[]; +}) { + const scrollRef = React.useRef(null); + const [canScrollUp, setCanScrollUp] = React.useState(false); + const [canScrollDown, setCanScrollDown] = React.useState(false); + const orderedOptions = React.useMemo(() => { + const group = ({ runtime }: HarnessConnectionOption) => { + if (runtime.id === "buzz-agent") return 0; + if (runtime.id === "goose") return 1; + return installedIds.has(runtime.id) ? 2 : 3; + }; + return [...options].sort((left, right) => group(left) - group(right)); + }, [installedIds, options]); + const updateScrollEdges = React.useCallback(() => { + const element = scrollRef.current; + if (!element) return; + setCanScrollUp(element.scrollTop > 1); + setCanScrollDown( + element.scrollTop + element.clientHeight < element.scrollHeight - 1, + ); + }, []); + + React.useEffect(() => { + updateScrollEdges(); + const element = scrollRef.current; + if (!element) return; + const observer = new ResizeObserver(updateScrollEdges); + observer.observe(element); + return () => observer.disconnect(); + }, [updateScrollEdges]); + + return ( +
+ @@ -124,301 +138,6 @@ function Landing({ ); } -type PreviewPasswordStrength = "empty" | "weak" | "moderate" | "strong"; - -const strengthLabelPhraseVariants = { - animate: { transition: { staggerChildren: 0.008 } }, - exit: { transition: { staggerChildren: 0.005 } }, - initial: {}, -}; - -const strengthLabelCharacterVariants = { - animate: { - filter: "blur(0)", - opacity: 1, - transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const }, - y: 0, - }, - exit: { - filter: "blur(0.25rem)", - opacity: 0, - transition: { duration: 0.16, ease: [0.64, 0, 0.78, 0] as const }, - y: "-0.5rem", - }, - initial: { filter: "blur(0.25rem)", opacity: 0, y: "0.5rem" }, -}; - -function previewPasswordStrength(password: string): PreviewPasswordStrength { - if (password.length === 0) return "empty"; - if (password.length < 8) return "weak"; - if (password.length < 15) return "moderate"; - return "strong"; -} - -function PasswordStrengthMeter({ - descriptionId, - password, -}: { - descriptionId: string; - password: string; -}) { - const strength = previewPasswordStrength(password); - const reduceMotion = useReducedMotion() ?? false; - const activeCount = - strength === "strong" - ? 3 - : strength === "moderate" - ? 2 - : strength === "weak" - ? 1 - : 0; - const label = - strength === "empty" - ? "Empty" - : strength === "weak" - ? "Weak" - : strength === "moderate" - ? "Moderate" - : "Strong"; - const labelCharacters = React.useMemo(() => { - const occurrences = new Map(); - return [...label].map((character) => { - const occurrence = occurrences.get(character) ?? 0; - occurrences.set(character, occurrence + 1); - return { character, key: `${character}-${occurrence}` }; - }); - }, [label]); - - return ( -
- - {reduceMotion ? ( - {strength === "empty" ? "" : label} - ) : ( - - {strength === "empty" ? null : ( - - {labelCharacters.map(({ character, key }) => ( - - {character} - - ))} - - )} - - )} - - - {[3, 2, 1].map((level) => { - const isTopDot = level === 3; - const positionClass = - level === 3 - ? strength === "strong" - ? "top-1/2 -translate-y-1/2" - : "top-1" - : level === 2 - ? "top-[13px]" - : "top-[22px]"; - return ( - = level && - "bg-amber-500", - strength === "weak" && activeCount >= level && "bg-red-500", - strength !== "strong" && - activeCount < level && - "bg-foreground/20", - )} - data-level={level} - key={level} - > - {isTopDot ? ( - - ) : null} - - ); - })} - - - Password strength: {label} - -
- ); -} - -function PreviewCredentialsFields({ - email, - emailId, - onEmailChange, - onPasswordChange, - onConfirmPasswordChange, - confirmPassword, - password, - passwordAutoComplete, - passwordHelp, - passwordId, - passwordPlaceholder, - showPasswordStrength = false, -}: { - email: string; - emailId: string; - onEmailChange: (value: string) => void; - onPasswordChange: (value: string) => void; - onConfirmPasswordChange?: (value: string) => void; - confirmPassword?: string; - password: string; - passwordAutoComplete: "current-password" | "new-password"; - passwordHelp?: React.ReactNode; - passwordId: string; - passwordPlaceholder: string; - showPasswordStrength?: boolean; -}) { - const cardLayout = useOnboardingPreviewCardLayout(); - const passwordStrengthDescriptionId = React.useId(); - - return ( - <> -
- - onEmailChange(event.target.value)} - placeholder="Enter your email address" - smooth={cardLayout} - type="email" - value={email} - /> -
-
- -
- onPasswordChange(event.target.value)} - placeholder={passwordPlaceholder} - smooth={cardLayout} - type="password" - value={password} - /> - {showPasswordStrength ? ( - - ) : null} -
- {passwordHelp ? ( -
{passwordHelp}
- ) : null} - {onConfirmPasswordChange ? ( -
- - onConfirmPasswordChange(event.target.value)} - placeholder="Confirm your password" - smooth={cardLayout} - type="password" - value={confirmPassword ?? ""} - /> -
- ) : null} -
- - ); -} - function EmailSignup({ password, onBack, @@ -666,6 +385,19 @@ export function OnboardingPreviewApp() { const [signInEmail, setSignInEmail] = React.useState(""); const [signupPassword, setSignupPassword] = React.useState(""); const [passwordResetEmail, setPasswordResetEmail] = React.useState(""); + const [installedHarnessIds, setInstalledHarnessIds] = React.useState( + () => + new Set( + HARNESS_CONNECTION_OPTIONS.filter( + ({ runtime }) => runtime.availability === "available", + ).map(({ runtime }) => runtime.id), + ), + ); + const [selectedHarnessId, setSelectedHarnessId] = React.useState("claude"); + const [harnessConnectionMethod, setHarnessConnectionMethod] = + React.useState("subscription"); + const [harnessConnectionInOnboarding, setHarnessConnectionInOnboarding] = + React.useState(true); const [setupBackPage, setSetupBackPage] = React.useState("landing"); const [communityRoute, setCommunityRoute] = @@ -674,7 +406,14 @@ export function OnboardingPreviewApp() { const [displayName, setDisplayName] = React.useState(""); const [avatarUrl, setAvatarUrl] = React.useState(""); const backupSession = useEncryptedBackupSession(); - const journey = ONBOARDING_PREVIEW_JOURNEYS[variant]; + const journey = resolveOnboardingPreviewJourney( + variant, + harnessConnectionInOnboarding, + ); + const selectedHarness = + HARNESS_CONNECTION_OPTIONS.find( + ({ runtime }) => runtime.id === selectedHarnessId, + ) ?? HARNESS_CONNECTION_OPTIONS[0]; React.useEffect(() => { if (page === "landing") { @@ -689,6 +428,15 @@ export function OnboardingPreviewApp() { resetEncryptedBackupSession(backupSession); setPage("landing"); setPasswordResetEmail(""); + setInstalledHarnessIds( + new Set( + HARNESS_CONNECTION_OPTIONS.filter( + ({ runtime }) => runtime.availability === "available", + ).map(({ runtime }) => runtime.id), + ), + ); + setSelectedHarnessId("claude"); + setHarnessConnectionMethod("subscription"); setRun((current) => current + 1); setSignInEmail(""); setSignupPassword(""); @@ -716,7 +464,7 @@ export function OnboardingPreviewApp() { let content: React.ReactNode; if (page === "landing") { - content = ; + content = ; } else if (page === "email") { content = ( setPage("backup-options")} + onBack={() => setPage(variant === "v3" ? "backup-options" : "landing")} testId="onboarding-preview-identity-key" total={journey.totalSteps} > @@ -747,13 +495,15 @@ export function OnboardingPreviewApp() { previewMode returningFromSecurity={false} showPreviewBackupShortcut={false} + v3Presentation={variant === "v3"} /> ); } else if (page === "backup-options") { content = ( setPage("landing")} + onBack={() => setPage(variant === "v3" ? "landing" : "identity-key")} + security={variant === "today"} testId="onboarding-preview-backup-options" total={journey.totalSteps} > @@ -767,6 +517,7 @@ export function OnboardingPreviewApp() { optionsExpanded previewMode returningFromSecurity={false} + v3Presentation={variant === "v3"} /> ); @@ -792,6 +543,7 @@ export function OnboardingPreviewApp() { onDone={() => setPage("identity-key")} session={backupSession} total={journey.totalSteps} + v3Presentation={variant === "v3"} /> ); } else if (page === "sign-in") { @@ -820,7 +572,7 @@ export function OnboardingPreviewApp() { } else if (page === "sign-in-key") { content = ( setPage("sign-in")} + onBack={() => setPage(variant === "v3" ? "sign-in" : "landing")} onContinue={() => continueFromAccount("sign-in-key")} total={journey.totalSteps} /> @@ -832,6 +584,38 @@ export function OnboardingPreviewApp() { onNext={() => setPage("config")} /> ); + } else if (page === "harness-connection") { + content = ( + setPage(setupBackPage)} + onInstall={(option) => { + setInstalledHarnessIds((current) => + new Set(current).add(option.runtime.id), + ); + setSelectedHarnessId(option.runtime.id); + setHarnessConnectionMethod(option.methods[0] ?? "api"); + setPage("harness-connection-detail"); + }} + onSelect={(option) => { + setSelectedHarnessId(option.runtime.id); + setHarnessConnectionMethod(option.methods[0] ?? "api"); + setPage("harness-connection-detail"); + }} + total={journey.totalSteps} + /> + ); + } else if (page === "harness-connection-detail") { + content = ( + setPage("harness-connection")} + onContinue={() => setPage("community-choice")} + onMethodChange={setHarnessConnectionMethod} + option={selectedHarness} + total={journey.totalSteps} + /> + ); } else if (page === "config") { content = ( { + setHarnessConnectionInOnboarding(included); + restart(); + }} onRestart={restart} onVariantChange={changeVariant} variant={variant} diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx index 8b4d498de7..ae15ebc9f9 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewControls.tsx @@ -2,6 +2,7 @@ import { RotateCcw, ShieldCheck } from "lucide-react"; import type { OnboardingPreviewVariant } from "../onboardingPreview"; import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; import { SegmentedControl } from "@/shared/ui/segmented-control"; const PREVIEW_VARIANT_OPTIONS = [ @@ -10,10 +11,14 @@ const PREVIEW_VARIANT_OPTIONS = [ ] as const; export function OnboardingPreviewControls({ + harnessConnectionInOnboarding, + onHarnessConnectionInOnboardingChange, onRestart, onVariantChange, variant, }: { + harnessConnectionInOnboarding: boolean; + onHarnessConnectionInOnboardingChange: (included: boolean) => void; onRestart: () => void; onVariantChange: (variant: OnboardingPreviewVariant) => void; variant: OnboardingPreviewVariant; @@ -56,6 +61,22 @@ export function OnboardingPreviewControls({ testId="onboarding-preview-variant" value={variant} /> + {variant === "v3" ? ( + + ) : null} ); } diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewCredentials.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewCredentials.tsx new file mode 100644 index 0000000000..eb973de91d --- /dev/null +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewCredentials.tsx @@ -0,0 +1,303 @@ +import { Check } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { OnboardingPreviewInput } from "./OnboardingPreviewInput"; +import { useOnboardingPreviewCardLayout } from "./OnboardingPreviewShell"; +import { ONBOARDING_PREVIEW_CARD_INPUT_CLASS } from "./onboardingPreviewCardStyles"; + +type PreviewPasswordStrength = "empty" | "weak" | "moderate" | "strong"; + +const strengthLabelPhraseVariants = { + animate: { transition: { staggerChildren: 0.008 } }, + exit: { transition: { staggerChildren: 0.005 } }, + initial: {}, +}; + +const strengthLabelCharacterVariants = { + animate: { + filter: "blur(0)", + opacity: 1, + transition: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const }, + y: 0, + }, + exit: { + filter: "blur(0.25rem)", + opacity: 0, + transition: { duration: 0.16, ease: [0.64, 0, 0.78, 0] as const }, + y: "-0.5rem", + }, + initial: { filter: "blur(0.25rem)", opacity: 0, y: "0.5rem" }, +}; + +function previewPasswordStrength(password: string): PreviewPasswordStrength { + if (password.length === 0) return "empty"; + if (password.length < 8) return "weak"; + if (password.length < 15) return "moderate"; + return "strong"; +} + +function PasswordStrengthMeter({ + descriptionId, + password, +}: { + descriptionId: string; + password: string; +}) { + const strength = previewPasswordStrength(password); + const reduceMotion = useReducedMotion() ?? false; + const activeCount = + strength === "strong" + ? 3 + : strength === "moderate" + ? 2 + : strength === "weak" + ? 1 + : 0; + const label = + strength === "empty" + ? "Empty" + : strength === "weak" + ? "Weak" + : strength === "moderate" + ? "Moderate" + : "Strong"; + const labelCharacters = React.useMemo(() => { + const occurrences = new Map(); + return [...label].map((character) => { + const occurrence = occurrences.get(character) ?? 0; + occurrences.set(character, occurrence + 1); + return { character, key: `${character}-${occurrence}` }; + }); + }, [label]); + + return ( +
+ + {reduceMotion ? ( + {strength === "empty" ? "" : label} + ) : ( + + {strength === "empty" ? null : ( + + {labelCharacters.map(({ character, key }) => ( + + {character} + + ))} + + )} + + )} + + + {[3, 2, 1].map((level) => { + const isTopDot = level === 3; + const positionClass = + level === 3 + ? strength === "strong" + ? "top-1/2 -translate-y-1/2" + : "top-1" + : level === 2 + ? "top-[13px]" + : "top-[22px]"; + return ( + = level && + "bg-amber-500", + strength === "weak" && activeCount >= level && "bg-red-500", + strength !== "strong" && + activeCount < level && + "bg-foreground/20", + )} + data-level={level} + key={level} + > + {isTopDot ? ( + + ) : null} + + ); + })} + + + Password strength: {label} + +
+ ); +} + +export function PreviewCredentialsFields({ + email, + emailId, + onEmailChange, + onPasswordChange, + onConfirmPasswordChange, + confirmPassword, + password, + passwordAutoComplete, + passwordHelp, + passwordId, + passwordPlaceholder, + showPasswordStrength = false, +}: { + email: string; + emailId: string; + onEmailChange: (value: string) => void; + onPasswordChange: (value: string) => void; + onConfirmPasswordChange?: (value: string) => void; + confirmPassword?: string; + password: string; + passwordAutoComplete: "current-password" | "new-password"; + passwordHelp?: React.ReactNode; + passwordId: string; + passwordPlaceholder: string; + showPasswordStrength?: boolean; +}) { + const cardLayout = useOnboardingPreviewCardLayout(); + const passwordStrengthDescriptionId = React.useId(); + + return ( + <> +
+ + onEmailChange(event.target.value)} + placeholder="Enter your email address" + smooth={cardLayout} + type="email" + value={email} + /> +
+
+ +
+ onPasswordChange(event.target.value)} + placeholder={passwordPlaceholder} + smooth={cardLayout} + type="password" + value={password} + /> + {showPasswordStrength ? ( + + ) : null} +
+ {passwordHelp ? ( +
{passwordHelp}
+ ) : null} + {onConfirmPasswordChange ? ( +
+ + onConfirmPasswordChange(event.target.value)} + placeholder="Confirm your password" + smooth={cardLayout} + type="password" + value={confirmPassword ?? ""} + /> +
+ ) : null} +
+ + ); +} diff --git a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx index f052a1daa4..c7d0d7dce9 100644 --- a/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingPreviewJourney.tsx @@ -41,11 +41,13 @@ export function BackupPasswordPreview({ onDone, session, total, + v3Presentation = false, }: { onBack: () => void; onDone: () => void; session: EncryptedBackupSession; total: number; + v3Presentation?: boolean; }) { return ( ); @@ -335,7 +338,7 @@ export function CommunityChoicePreview({ className={cn( "flex w-full flex-1 flex-col", cardLayout - ? "items-stretch justify-start gap-2 py-6" + ? "-mx-2 w-[calc(100%+1rem)] items-stretch justify-start gap-2 py-6" : "translate-y-16 items-center justify-center gap-20 py-8", )} > diff --git a/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx b/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx index a41f01e039..cbe3667baf 100644 --- a/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx +++ b/desktop/src/features/onboarding/ui/WelcomeChannelAppPreview.tsx @@ -1,36 +1,268 @@ import { + ArrowUp, AtSign, Bot, ChevronLeft, ChevronRight, FolderKanban, Hash, + Headphones, Home, Lock, - Mic, + MoreVertical, Paperclip, Plus, Search, - Send, Smile, - Sparkles, + TerminalSquare, + Users, } from "lucide-react"; import * as React from "react"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { ChannelIntroBlock } from "@/features/messages/ui/ChannelIntroBlock"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; -import { Button } from "@/shared/ui/button"; -import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; +import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; const CHANNEL_ROW_CLASS = - "flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors"; + "flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors hover:bg-black/[0.06]"; const WELCOME_TEAM = [ { name: "Fizz", image: "/onboarding/starter-team/fizz.png" }, { name: "Honey", image: "/onboarding/starter-team/honey.png" }, { name: "Pollen", image: "/onboarding/starter-team/pollen.png" }, ] as const; +const WELCOME_PREVIEW_MESSAGE = + "Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build."; + +type PreviewThemeStyle = React.CSSProperties & Record<`--${string}`, string>; + +const PREVIEW_APP_THEME: PreviewThemeStyle = { + colorScheme: "light", + "--accent": "0 0% 94%", + "--accent-foreground": "0 0% 10%", + "--background": "0 0% 100%", + "--border": "0 0% 87%", + "--card": "0 0% 100%", + "--card-foreground": "0 0% 10%", + "--foreground": "0 0% 10%", + "--input": "0 0% 84%", + "--muted": "0 0% 94%", + "--muted-foreground": "0 0% 42%", + "--popover": "0 0% 100%", + "--popover-foreground": "0 0% 10%", + "--primary": "0 0% 10%", + "--primary-foreground": "0 0% 100%", + "--ring": "0 0% 20%", + "--secondary": "0 0% 96%", + "--secondary-foreground": "0 0% 10%", + "--sidebar": "48 48% 88%", + "--sidebar-accent": "0 0% 100%", + "--sidebar-accent-foreground": "0 0% 10%", + "--sidebar-background": "48 48% 88%", + "--sidebar-border": "0 0% 60%", + "--sidebar-foreground": "0 0% 18%", + "--sidebar-ring": "0 0% 20%", +}; + +function PreviewAvatar({ + avatarUrl, + label, + size = "small", +}: { + avatarUrl: string; + label: string; + size?: "small" | "message"; +}) { + const className = + size === "message" + ? "h-9 w-9 rounded-lg text-xs" + : "h-8 w-8 rounded-lg text-xs"; + if (avatarUrl) { + return ( + + ); + } + + return ( + + {label.trim().charAt(0).toUpperCase() || "Y"} + + ); +} + +function PreviewCommunityRail({ communityName }: { communityName: string }) { + return ( + + ); +} + +function PreviewSidebar({ + avatarUrl, + channelName, + communityName, + profileName, + onPreviewAction, +}: { + avatarUrl: string; + channelName: string; + communityName: string; + profileName: string; + onPreviewAction: (action: string) => void; +}) { + return ( + + ); +} export function WelcomeChannelAppPreview({ avatarUrl, @@ -43,11 +275,10 @@ export function WelcomeChannelAppPreview({ }) { const [draft, setDraft] = React.useState(""); const [messages, setMessages] = React.useState([]); - const [channelName, setChannelName] = React.useState("Welcome"); - const [showRenamePrompt, setShowRenamePrompt] = React.useState(true); const [activePreviewAction, setActivePreviewAction] = React.useState< string | null >(null); + const channelName = "Welcome"; const profileName = displayName.trim() || "Your profile"; const submitDraft = (event: React.FormEvent) => { event.preventDefault(); @@ -60,19 +291,19 @@ export function WelcomeChannelAppPreview({ const intro = { actions: [ { - icon: , + icon: , label: "Browse channels", onClick: () => setActivePreviewAction("Browse channels"), testId: "welcome-intro-action-browse-channels", }, { - icon: , + icon: , label: "Create a channel", onClick: () => setActivePreviewAction("Create a channel"), testId: "welcome-intro-action-create-channel", }, { - icon: , + icon: , label: "Create an agent", onClick: () => setActivePreviewAction("Create an agent"), testId: "welcome-intro-action-create-agent", @@ -81,333 +312,181 @@ export function WelcomeChannelAppPreview({ channelKindLabel: "private welcome channel", channelName, description: null, - icon: , }; return (
-
-
- - -
- -
-
- -
- + -
-
- -

- {channelName} -

-
+
+
+ -
-
- - -
-

- Welcome to {communityName} +

+ Fizz +
+
+

Fizz

+

Now

+
+

+ {WELCOME_PREVIEW_MESSAGE}

-

- Everyone starts with a private channel to get settled. This - one is yours. Use it to try out features, draft messages, or - work privately with agents. -

-
- - {showRenamePrompt ? ( -
-
- -
-
-

- Make this space yours -

-

- This private channel is yours to keep. Rename it for how - you want to use it. -

-
- -
- ) : null} - -
-
-
- {WELCOME_TEAM.map((agent) => ( - {agent.name} - ))} -
-
-
-

- Bring your starter team online -

- - Not connected - -
-

- Connect an AI provider to start Fizz, Honey, and Pollen. - They can help you learn Buzz and work through something - you’re building. -

-
-
-
-

- You can change providers or agents later. -

- -
-
- - {messages.map((message, index) => ( -
- -
-

{profileName}

-

{message}

-
-
- ))} +
-
-
-
- -
( +
-