feat(onboarding): refine V3 identity and welcome flow

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-30 11:01:16 +01:00
parent a5fcda05aa
commit 826aaf2908
8 changed files with 248 additions and 210 deletions

View File

@@ -17,8 +17,8 @@
"windows": [
{
"title": "",
"width": 800,
"height": 600,
"width": 900,
"height": 650,
"maximized": true,
"visible": false,
"transparent": false,
@@ -30,8 +30,8 @@
"y": 25
},
"backgroundThrottling": "disabled",
"minWidth": 800,
"minHeight": 500
"minWidth": 900,
"minHeight": 650
}
],
"macOSPrivateApi": true,

View File

@@ -29,7 +29,10 @@ import {
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
import { OnboardingFooter } from "./OnboardingFooter";
import { useOnboardingPreviewCardLayout } from "./OnboardingPreviewShell";
import { ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS } from "./onboardingPreviewCardStyles";
import {
ONBOARDING_PREVIEW_NEUTRAL_SURFACE_CLASS,
ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS,
} from "./onboardingPreviewCardStyles";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -53,6 +56,10 @@ let introPlayed = false;
const REVEAL_ANIMATION_CLASS =
"animate-in fade-in duration-700 motion-reduce:animate-none";
const V3_KEY_GUIDANCE_ICON_CLASS = cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-foreground/80",
ONBOARDING_PREVIEW_NEUTRAL_SURFACE_CLASS,
);
const BACKUP_OPTION_CLASS =
"flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground";
@@ -222,8 +229,8 @@ export function BackupStep({
>
<div
className={cn(
"flex w-full max-w-140 shrink-0 flex-col",
cardLayout ? "text-left" : "text-center",
"mx-auto flex w-full shrink-0 flex-col",
cardLayout ? "max-w-[500px] text-left" : "max-w-140 text-center",
)}
>
<h1 className="text-title font-normal text-foreground">
@@ -256,13 +263,13 @@ export function BackupStep({
</div>
{v3Presentation ? (
<div className="flex w-full max-w-[560px] flex-1 items-center py-10">
<div className="mx-auto flex w-full max-w-[500px] flex-1 items-center py-10">
<div
className="w-full space-y-6"
data-testid="onboarding-preview-key-guidance"
>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-foreground/80">
<span className={V3_KEY_GUIDANCE_ICON_CLASS}>
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
</span>
<p className="text-base leading-6 text-foreground">
@@ -270,7 +277,7 @@ export function BackupStep({
</p>
</div>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-foreground/80">
<span className={V3_KEY_GUIDANCE_ICON_CLASS}>
<CircleSlash2 className="h-5 w-5" aria-hidden="true" />
</span>
<p className="text-base leading-6 text-foreground">
@@ -278,7 +285,7 @@ export function BackupStep({
</p>
</div>
<div className="flex min-h-14 items-center gap-4 text-left">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground/10 text-foreground/80">
<span className={V3_KEY_GUIDANCE_ICON_CLASS}>
<HardDriveDownload className="h-5 w-5" aria-hidden="true" />
</span>
<p className="text-base leading-6 text-foreground">
@@ -486,117 +493,94 @@ export function BackupStep({
className={cn("py-6", cardLayout ? "px-0" : "px-8")}
variant="textured"
>
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
<div className="min-w-0 flex-1">
{canUseSpoilerParticles ? (
<div
className="buzz-onboarding-private-key-spoiler"
data-testid="backup-key-spoiler"
<div className="mx-auto w-full min-w-0 max-w-[832px]">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
{canUseSpoilerParticles ? (
<div
className="buzz-onboarding-private-key-spoiler"
data-testid="backup-key-spoiler"
>
<SpoilerInline block revealOnHover>
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
v3Presentation && "buzz-onboarding-key-text-v3",
"select-text",
)}
data-testid="backup-key-value"
>
{PREVIEW_KEY_TEXT}
</p>
</SpoilerInline>
</div>
) : (
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
v3Presentation && "buzz-onboarding-key-text-v3",
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
>
{isRevealed && nsec ? nsec : maskedKey}
</p>
)}
</div>
{canUseSpoilerParticles ? null : (
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
<SpoilerInline block revealOnHover>
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
"select-text",
)}
data-testid="backup-key-value"
>
{PREVIEW_KEY_TEXT}
</p>
</SpoilerInline>
</div>
) : (
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
)}
data-testid="backup-key-value"
>
{isRevealed && nsec ? nsec : maskedKey}
</p>
</Button>
)}
</div>
{canUseSpoilerParticles ? null : (
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
)}
</Button>
)}
{v3Presentation ? (
<div className="mt-4 flex justify-start">
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"-ml-5 gap-2 bg-transparent px-5 text-foreground/80 hover:bg-[#e2e2e2]/50 hover:text-foreground",
)}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
</div>
) : null}
</div>
</Card>
{v3Presentation ? (
<div
className={cn(
"mt-8 flex flex-wrap items-center gap-3",
cardLayout ? "justify-start" : "justify-center",
)}
data-testid="backup-options"
>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
cardLayout && ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS,
"gap-2 px-5",
)}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
cardLayout && ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS,
"gap-2 px-5",
)}
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
{lockedBackupCreated ? (
<Check className="h-5 w-5" aria-hidden="true" />
) : (
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
)}
{lockedBackupCreated
? "Created locked backup"
: "Create locked backup"}
</Button>
</div>
) : null}
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
@@ -621,7 +605,24 @@ export function BackupStep({
</div>
)}
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
<OnboardingFooter className={cn(REVEAL_ANIMATION_CLASS, "flex-nowrap")}>
{v3Presentation && created ? (
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
cardLayout && ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS,
"px-4",
)}
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
{lockedBackupCreated
? "Created locked backup"
: "Create locked backup"}
</Button>
) : null}
{v3Presentation && created && showPreviewBackupShortcut ? (
<Button
className={cn(ONBOARDING_SECONDARY_CTA_CLASS, "gap-2")}

View File

@@ -58,7 +58,6 @@ import {
import { OnboardingPreviewControls } from "./OnboardingPreviewControls";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { SetupStepPreview } from "./SetupStepPreview";
import { ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS } from "./onboardingPreviewCardStyles";
const EMAIL_SIGNUP_POLICY: JoinPolicy = {
ageAttestationRequired: true,
@@ -335,30 +334,16 @@ function EmailSignIn({
passwordId="onboarding-preview-sign-in-password"
passwordPlaceholder="Enter your password"
/>
<div
className={cn(
"flex w-full flex-col gap-3 pt-1",
cardLayout ? "items-start" : "items-center",
)}
>
<div
aria-hidden
className="flex w-full max-w-64 items-center gap-3 text-xs text-foreground/70"
>
<span className="h-px flex-1 bg-foreground/20" />
<span>or</span>
<span className="h-px flex-1 bg-foreground/20" />
</div>
<div className="flex w-full items-baseline gap-1.5 pt-1 text-sm text-foreground/70">
<span>or</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
cardLayout && ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS,
)}
className="h-auto p-0 text-sm text-foreground"
data-testid="onboarding-preview-sign-in-with-key"
onClick={onSignInWithKey}
type="button"
variant="ghost"
variant="link"
>
Sign in with a key
Sign in with private key
</Button>
</div>
</form>
@@ -478,6 +463,7 @@ export function OnboardingPreviewApp() {
} else if (page === "identity-key") {
content = (
<OnboardingPreviewStep
allowHorizontalActionOverflow={variant === "v3"}
onBack={() => setPage(variant === "v3" ? "backup-options" : "landing")}
testId="onboarding-preview-identity-key"
total={journey.totalSteps}
@@ -540,7 +526,11 @@ export function OnboardingPreviewApp() {
content = (
<BackupPasswordPreview
onBack={() => setPage("identity-key")}
onDone={() => setPage("identity-key")}
onDone={() =>
variant === "v3"
? continueFromAccount("identity-key")
: setPage("identity-key")
}
session={backupSession}
total={journey.totalSteps}
v3Presentation={variant === "v3"}

View File

@@ -238,12 +238,17 @@ export function PreviewCredentialsFields({
/>
</div>
<div>
<label
className="mb-2 block text-sm font-medium text-foreground"
htmlFor={passwordId}
>
Password
</label>
<div className="mb-2 flex items-center justify-between gap-3">
<label
className="block text-sm font-medium text-foreground"
htmlFor={passwordId}
>
Password
</label>
{passwordHelp ? (
<div className="shrink-0 text-right">{passwordHelp}</div>
) : null}
</div>
<div className="relative">
<OnboardingPreviewInput
aria-describedby={
@@ -270,9 +275,6 @@ export function PreviewCredentialsFields({
/>
) : null}
</div>
{passwordHelp ? (
<div className="mt-2 text-left">{passwordHelp}</div>
) : null}
{onConfirmPasswordChange ? (
<div className="mt-5">
<label

View File

@@ -29,6 +29,7 @@ export function useOnboardingPreviewCardLayout() {
}
export function OnboardingPreviewStep({
allowHorizontalActionOverflow = false,
children,
current = 2,
onBack,
@@ -36,6 +37,7 @@ export function OnboardingPreviewStep({
testId,
total,
}: {
allowHorizontalActionOverflow?: boolean;
children: React.ReactNode;
current?: number;
onBack?: () => void;
@@ -51,7 +53,12 @@ export function OnboardingPreviewStep({
className={cn(
"buzz-onboarding-step-frame relative flex w-full flex-col",
cardLayout
? "-mx-2 min-h-0 w-[calc(100%+1rem)] max-w-none flex-1 items-stretch overflow-hidden px-2 text-left"
? cn(
"min-h-0 max-w-none flex-1 items-stretch overflow-x-hidden overflow-y-auto overscroll-contain text-left",
allowHorizontalActionOverflow
? "-mx-6 w-[calc(100%+3rem)] px-6"
: "-mx-2 w-[calc(100%+1rem)] px-2",
)
: "max-w-[1040px] flex-1 items-center text-center",
)}
>
@@ -75,7 +82,7 @@ export function OnboardingPreviewStep({
{cardLayout ? (
<Card
className={cn(
"flex h-[min(41.5rem,calc(100dvh-3rem))] w-full max-w-[47rem] flex-col overflow-hidden rounded-[2rem] bg-white px-6 pb-6 pt-[4.5rem] text-left shadow-lg [--buzz-onboarding-cta-label:#fff] sm:px-[7.5rem] [&_.buzz-onboarding-transition-content]:!text-left [&_.buzz-onboarding-transition-line]:justify-start [&_h1+p]:!mx-0 [&_h1+p]:!mt-2 [&_h1+p]:!text-left [&_h1+p]:!text-base [&_h1+p]:!leading-6 [&_h1]:!text-left [&_h1]:!text-2xl [&_h1]:!leading-8 [&_h1]:!text-foreground",
"flex h-[min(41.5rem,calc(100dvh-3rem))] w-full max-w-[47rem] flex-col overflow-hidden rounded-[2rem] bg-white px-6 pb-6 pt-[4.5rem] text-left shadow-lg [--buzz-onboarding-cta-label:#fff] sm:px-[7.5rem] [&_.buzz-onboarding-transition-content]:!text-left [&_.buzz-onboarding-transition-line]:justify-center [&_h1+p]:!mx-0 [&_h1+p]:!mt-2 [&_h1+p]:!text-left [&_h1+p]:!text-base [&_h1+p]:!leading-6 [&_h1]:!text-left [&_h1]:!text-2xl [&_h1]:!leading-8 [&_h1]:!text-foreground",
)}
data-testid="onboarding-preview-content-card"
ref={cardRef}

View File

@@ -24,7 +24,6 @@ import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdro
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 =
@@ -34,9 +33,6 @@ const WELCOME_TEAM = [
{ 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 = {
@@ -104,45 +100,6 @@ function PreviewAvatar({
);
}
function PreviewCommunityRail({ communityName }: { communityName: string }) {
return (
<nav
aria-label="Communities"
className="flex w-14 shrink-0 flex-col items-center gap-2.5 px-2.5 pb-5 pt-12 text-black"
>
<button
aria-label={communityName}
className="relative flex size-9 items-center justify-center rounded-xl bg-black text-[#f4ef82]"
type="button"
>
<span className="absolute -left-2.5 h-5 w-1 rounded-r-full bg-black" />
<BuzzMark className="h-5 w-7" />
</button>
{WELCOME_TEAM.map((agent) => (
<button
aria-label={`${agent.name} community`}
className="size-9 overflow-hidden rounded-xl bg-white/65"
key={agent.name}
type="button"
>
<img
alt=""
className="h-full w-full object-contain"
src={agent.image}
/>
</button>
))}
<button
aria-label="Add community"
className="flex size-9 items-center justify-center rounded-2xl bg-white/65 text-black/65 transition-[border-radius,background-color] hover:rounded-xl hover:bg-black hover:text-white"
type="button"
>
<Plus className="size-4" />
</button>
</nav>
);
}
function PreviewSidebar({
avatarUrl,
channelName,
@@ -275,10 +232,11 @@ export function WelcomeChannelAppPreview({
}) {
const [draft, setDraft] = React.useState("");
const [messages, setMessages] = React.useState<string[]>([]);
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();
@@ -321,7 +279,6 @@ export function WelcomeChannelAppPreview({
style={PREVIEW_APP_THEME}
>
<StartupWindowDragRegion />
<PreviewCommunityRail communityName={communityName} />
<PreviewSidebar
avatarUrl={avatarUrl}
channelName={channelName}
@@ -387,22 +344,98 @@ export function WelcomeChannelAppPreview({
<div className="mx-auto w-full max-w-[920px]">
<ChannelIntroBlock intro={intro} />
<div className="mt-8 flex items-start gap-3 px-3 text-left">
<img
alt="Fizz"
className="size-9 rounded-lg object-contain"
src="/onboarding/starter-team/fizz.png"
/>
<div className="min-w-0 max-w-[680px]">
<div className="flex items-baseline gap-2">
<p className="text-sm font-semibold">Fizz</p>
<p className="text-xs text-muted-foreground">Now</p>
<section
className="mx-3 mt-7 max-w-[680px] border-t border-border/60 pt-6 text-left"
data-testid="welcome-preview-community-message"
>
<p className="text-lg font-semibold">
Welcome to {communityName}
</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
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.
</p>
</section>
{showRenamePrompt ? (
<section
className="mx-3 mt-6 flex max-w-[680px] items-center gap-4 rounded-2xl border border-border/70 bg-background/70 px-5 py-4 text-left"
data-testid="welcome-preview-rename-prompt"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Lock className="size-4" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">Make this space yours</p>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
This private channel is yours to keep. Rename it for how you
want to use it.
</p>
</div>
<Button
className="h-9 shrink-0 rounded-full px-5"
onClick={() => {
setChannelName(
profileName === "Your profile"
? "My space"
: `${profileName}’s space`,
);
setShowRenamePrompt(false);
setActivePreviewAction("Channel details opened");
}}
type="button"
variant="outline"
>
Rename channel
</Button>
</section>
) : null}
<section
className="mx-3 mt-6 max-w-[680px] overflow-hidden rounded-2xl border border-border/70 bg-muted/35 text-left"
data-testid="welcome-preview-agent-activation"
>
<div className="flex items-center gap-4 p-5">
<div className="flex shrink-0 -space-x-3">
{WELCOME_TEAM.map((agent) => (
<img
alt={agent.name}
className="size-14 rounded-2xl border-2 border-background bg-background object-contain"
key={agent.name}
src={agent.image}
/>
))}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-base font-semibold">
Bring your starter team online
</p>
<span className="rounded-full bg-foreground/[0.08] px-2 py-0.5 text-xs font-medium text-muted-foreground">
Not connected
</span>
</div>
<p className="mt-1.5 text-sm leading-5 text-muted-foreground">
Connect an AI provider to start Fizz, Honey, and Pollen.
They can help you learn Buzz and work through something
you’re building.
</p>
</div>
<p className="mt-1 whitespace-pre-line text-sm leading-5">
{WELCOME_PREVIEW_MESSAGE}
</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-border/60 bg-background/45 px-5 py-3">
<p className="text-xs text-muted-foreground">
You can change providers or agents later.
</p>
<Button
className="h-9 rounded-full px-5"
onClick={() => setActivePreviewAction("Connect AI provider")}
type="button"
>
Connect AI provider
</Button>
</div>
</section>
{messages.map((message, index) => (
<div

View File

@@ -2,5 +2,6 @@ export const ONBOARDING_PREVIEW_CARD_INPUT_CLASS =
"h-12 rounded-xl border-[#e2e2e2] bg-[#f9f9f9] px-4 text-[oklch(0.22213_0_0)] shadow-none transition-[border-color,box-shadow] duration-200 ease-out placeholder:text-[oklch(0.6901_0_0)] placeholder:opacity-60 hover:border-[#d6d6d6] focus-visible:border-[#e2e2e2] focus-visible:ring-0 focus-visible:shadow-[0_0_0_3px_white,0_0_0_6px_rgba(0,0,0,0.06)] motion-reduce:transition-none";
/** Quiet V3 actions use the same translucent neutral treatment as Back. */
export const ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS =
"bg-[#e2e2e2]/30 text-foreground hover:bg-[#e2e2e2]/50 hover:text-foreground";
export const ONBOARDING_PREVIEW_NEUTRAL_SURFACE_CLASS = "bg-[#e2e2e2]/30";
export const ONBOARDING_PREVIEW_SECONDARY_CTA_CLASS = `${ONBOARDING_PREVIEW_NEUTRAL_SURFACE_CLASS} text-foreground hover:bg-[#e2e2e2]/50 hover:text-foreground`;

View File

@@ -391,6 +391,10 @@
color: var(--buzz-onboarding-backup-ink);
}
.buzz-onboarding-key-text-v3 {
@apply max-w-[32ch] text-2xl font-medium text-foreground;
}
.dark .buzz-onboarding-neutral-theme:not(.buzz-startup-shell) {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;