feat(desktop): onboarding polish — docked CTAs, backup key card, dot grid (#2008)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
cynfria
2026-07-16 20:34:30 -07:00
committed by GitHub
parent 256542da41
commit 43bd104de5
21 changed files with 859 additions and 603 deletions

View File

@@ -20,6 +20,7 @@ export default defineConfig({
name: "smoke",
testMatch: [
"**/smoke.spec.ts",
"**/onboarding-docked-cta-screenshots.spec.ts",
"**/navigation.spec.ts",
"**/channels.spec.ts",
"**/channel-shared-header-backdrop.spec.ts",

View File

@@ -13,7 +13,7 @@ import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { OnboardingStepDots } from "@/features/onboarding/ui/OnboardingStepDots";
import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome";
type WelcomeSetupPage = "welcome" | "join" | "invite";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
@@ -95,7 +95,7 @@ export function WelcomeSetup({
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<OnboardingStepDots current={5} />
<OnboardingChrome current={5} />
<div className="relative flex w-full max-w-[760px] flex-col items-center text-center">
{page === "welcome" ? (
<OnboardingSlideTransition
@@ -105,7 +105,7 @@ export function WelcomeSetup({
transitionKey={`welcome-${welcomeEffect}-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
<h1 className="text-title font-normal">
Join or create a community
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
@@ -152,9 +152,7 @@ export function WelcomeSetup({
transitionKey={`join-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Join a community
</h1>
<h1 className="text-title font-normal">Join a community</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Send your public key to a community owner. Keep Buzz open; once
they add you, their invite link will continue setup here.
@@ -211,9 +209,7 @@ export function WelcomeSetup({
transitionKey={`invite-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
I have an invite link
</h1>
<h1 className="text-title font-normal">I have an invite link</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Keep this page open, then click the invite link you received.
Buzz will continue automatically. You can also paste it below.

View File

@@ -8,7 +8,8 @@ import { Plus } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { StepProgress } from "@/shared/ui/step-progress";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import {
@@ -26,8 +27,6 @@ type AvatarStepProps = {
submit: ProfileStepActions["submit"];
updateAvatarUrl: ProfileStepActions["updateAvatarUrl"];
};
/** Step number for the compact progress bar (mobile layout). */
currentStep: number;
direction: OnboardingTransitionDirection;
/** When true, a ghost "Skip for now" button is always visible (not just on error). */
showAlwaysSkip?: boolean;
@@ -35,8 +34,6 @@ type AvatarStepProps = {
ProfileStepState,
"avatar" | "isSaving" | "isUploadingAvatar" | "name" | "saveRecovery"
>;
/** Total steps for the compact progress bar (mobile layout). */
totalSteps?: number;
};
function ErrorBanner({ message }: { message: string | null }) {
@@ -125,7 +122,6 @@ function AvatarPreview({
function AvatarStepActions({
canSubmit,
currentStep,
hidden,
isSaving,
isUploadingAvatar,
@@ -135,10 +131,8 @@ function AvatarStepActions({
onSubmit,
saveRecovery,
showAlwaysSkip,
totalSteps,
}: {
canSubmit: boolean;
currentStep: number;
hidden: boolean;
isSaving: boolean;
isUploadingAvatar: boolean;
@@ -148,118 +142,108 @@ function AvatarStepActions({
onSubmit: () => void;
saveRecovery: ProfileStepState["saveRecovery"];
showAlwaysSkip: boolean;
totalSteps?: number;
}) {
const areNavigationActionsDisabled = isSaving || isUploadingAvatar;
return (
<AnimatePresence initial={false} mode="popLayout">
{hidden ? null : (
<motion.div
className="mx-auto mt-4 flex w-full max-w-[576px] origin-center flex-col gap-3 max-lg:pointer-events-none max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:z-40 max-lg:mt-0 max-lg:max-w-none max-lg:border-t max-lg:border-border max-lg:bg-background max-lg:p-4 max-lg:pb-[max(1rem,env(safe-area-inset-bottom))]"
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0.94,
}}
initial={{
opacity: 0,
scale: 0.94,
}}
transition={AVATAR_ACTIONS_MOTION_TRANSITION}
>
<Button
className="h-10 w-full max-lg:pointer-events-auto"
data-testid="onboarding-next"
disabled={!canSubmit}
onClick={onSubmit}
type="button"
<OnboardingFooter>
<AnimatePresence initial={false} mode="popLayout">
{hidden ? null : (
<motion.div
className="flex w-full origin-center flex-col items-center gap-3"
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0.94,
}}
initial={{
opacity: 0,
scale: 0.94,
}}
transition={AVATAR_ACTIONS_MOTION_TRANSITION}
>
{isSaving || isUploadingAvatar ? (
<Spinner
aria-label={isSaving ? "Saving profile" : "Uploading avatar"}
className="h-4 w-4 border-2"
/>
) : (
"Next"
)}
</Button>
{saveRecovery.canSkipForNow ? (
// Error-recovery path: exits onboarding entirely when there is no
// saved display name to fall back on.
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-skip"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={!canSubmit}
onClick={onSubmit}
type="button"
>
{isSaving || isUploadingAvatar ? (
<Spinner
aria-label={isSaving ? "Saving profile" : "Uploading avatar"}
className="h-4 w-4 border-2"
/>
) : (
"Next"
)}
</Button>
{saveRecovery.canSkipForNow ? (
// Error-recovery path: exits onboarding entirely when there is no
// saved display name to fall back on.
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
data-testid="onboarding-skip"
disabled={areNavigationActionsDisabled}
onClick={onSkipForNow}
type="button"
variant="ghost"
>
Skip for now
</Button>
) : showAlwaysSkip && !saveRecovery.errorMessage ? (
// Normal path: advances to the theme step without saving an avatar.
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
data-testid="onboarding-skip"
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
>
Skip for now
</Button>
) : null}
{saveRecovery.canAdvanceWithoutSaving ? (
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
data-testid="onboarding-next-without-saving"
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
>
Continue without saving
</Button>
) : null}
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
data-testid="onboarding-back"
disabled={areNavigationActionsDisabled}
onClick={onSkipForNow}
onClick={onBack}
type="button"
variant="ghost"
>
Skip for now
Back
</Button>
) : showAlwaysSkip && !saveRecovery.errorMessage ? (
// Normal path: advances to the theme step without saving an avatar.
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-skip"
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
>
Skip for now
</Button>
) : null}
{saveRecovery.canAdvanceWithoutSaving ? (
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-next-without-saving"
disabled={areNavigationActionsDisabled}
onClick={onContinueWithoutSaving}
type="button"
variant="ghost"
>
Continue without saving
</Button>
) : null}
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
data-testid="onboarding-back"
disabled={areNavigationActionsDisabled}
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
<StepProgress
activeSegmentClassName="bg-primary"
className="mt-1 max-lg:pointer-events-auto lg:hidden"
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalSteps}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</OnboardingFooter>
);
}
export function AvatarStep({
actions,
currentStep,
direction,
showAlwaysSkip = false,
state,
totalSteps,
}: AvatarStepProps) {
const {
advanceWithoutSaving,
@@ -328,7 +312,11 @@ export function AvatarStep({
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center pb-60 lg:pb-0"
// pb clears the always-docked footer: the emoji/color grid is the tallest
// onboarding content and overflows on short windows, so the shell's own
// bottom reserve isn't enough to scroll the last rows out from under the
// fixed CTA group + scrim.
className="flex w-full flex-col items-center pb-20"
data-testid="onboarding-page-avatar"
direction={direction}
transitionKey={`avatar-${direction}`}
@@ -341,10 +329,10 @@ export function AvatarStep({
>
<div className="flex w-full flex-col items-center text-center lg:items-start lg:text-left">
<div className="w-full max-w-[500px]">
<h1 className="text-3xl font-semibold text-foreground">
<h1 className="text-title font-normal text-foreground">
Next, add a display image
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
<p className="mt-5 text-sm leading-6 text-muted-foreground">
Choose an image or emoji as your avatar
</p>
</div>
@@ -411,7 +399,6 @@ export function AvatarStep({
<AvatarStepActions
canSubmit={canSubmit}
currentStep={currentStep}
hidden={areActionsHidden}
isSaving={isSaving}
isUploadingAvatar={isUploadingAvatar}
@@ -421,7 +408,6 @@ export function AvatarStep({
onSubmit={submit}
saveRecovery={saveRecovery}
showAlwaysSkip={showAlwaysSkip}
totalSteps={totalSteps}
/>
</motion.div>
</motion.div>

View File

@@ -4,6 +4,8 @@ import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -79,16 +81,16 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
transitionKey={`backup-${direction}`}
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
<h1 className="text-title font-normal text-foreground">
Your unique identity has been created
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
<p className="mt-5 text-sm leading-6 text-foreground/80">
This key is stored in your system keychain, but save it some place
safe in case you ever need to restore your account.
</p>
</div>
<div className="mt-10 w-full max-w-[640px]">
<div className="mt-10 w-full max-w-4xl">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/70">
<Spinner className="h-4 w-4 border-2" />
@@ -120,7 +122,8 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
</Button>
</div>
) : nsec ? (
<div className="rounded-3xl bg-white/85 px-6 py-5 shadow-[0_0_70px_45px_rgba(255,255,255,0.85)]">
// Translucent white card frames the key with equal padding.
<div className="w-full min-w-0 rounded-xl bg-white/50 px-8 py-6">
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
) : (
@@ -130,7 +133,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
)}
{nsec ? (
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-foreground/70">
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Never share your private key. Anyone can impersonate you and
@@ -140,9 +143,9 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
) : null}
</div>
<div className="mt-12 flex flex-col items-center gap-3">
<OnboardingFooter>
<Button
className="h-10 rounded-full px-8"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled({ isLoading, loadError })}
onClick={onNext}
@@ -172,7 +175,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
>
Back
</Button>
</div>
</OnboardingFooter>
</OnboardingSlideTransition>
);
}

View File

@@ -21,7 +21,11 @@ import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { OnboardingStepDots } from "./OnboardingStepDots";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
"--buzz-emoji-picker-rgb-background":
@@ -163,176 +167,178 @@ export function CommunityOnboardingFlow({
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 py-16 text-foreground"
className="buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 pb-28 pt-[106px] text-foreground"
data-testid="community-onboarding-flow"
>
<StartupWindowDragRegion />
{isProfileStage || isTeamStage ? (
<OnboardingStepDots current={isTeamStage ? 7 : 6} />
<OnboardingChrome current={isTeamStage ? 7 : 6} />
) : null}
<div
className={cn(
"relative my-auto w-full text-center",
isTeamStage ? "max-w-[760px]" : "max-w-[560px]",
)}
>
{transaction.stage === "claiming" ||
transaction.stage === "connecting" ? (
<>
<Users className="mx-auto h-10 w-10" />
<h1 className="mt-5 text-3xl font-semibold">
Joining {transaction.communityName}
</h1>
<p className="mt-3 text-sm text-foreground/80">
{transaction.error ??
(transaction.stage === "claiming"
? "Accepting your invite…"
: "Connecting securely…")}
</p>
<div className="mt-6 flex justify-center gap-3">
{transaction.error ? (
<Button className="rounded-full px-6" onClick={retryClaim}>
Retry
<OnboardingFooterProvider>
<div
className={cn(
"relative my-auto w-full text-center",
isTeamStage ? "max-w-[760px]" : "max-w-[560px]",
)}
>
{transaction.stage === "claiming" ||
transaction.stage === "connecting" ? (
<>
<Users className="mx-auto h-10 w-10" />
<h1 className="mt-5 text-title font-normal">
Joining {transaction.communityName}
</h1>
<p className="mt-3 text-sm text-foreground/80">
{transaction.error ??
(transaction.stage === "claiming"
? "Accepting your invite…"
: "Connecting securely…")}
</p>
<div className="mt-6 flex justify-center gap-3">
{transaction.error ? (
<Button className="rounded-full px-6" onClick={retryClaim}>
Retry
</Button>
) : null}
<Button
className="rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
onClick={clear}
variant="ghost"
>
Cancel
</Button>
) : null}
<Button
className="rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
onClick={clear}
variant="ghost"
>
Cancel
</Button>
</div>
</>
) : isProfileStage ? (
isAvatarEditorOpen ? (
<div className="relative rounded-3xl bg-white/85 px-6 py-8 shadow-[0_0_80px_50px_rgba(255,255,255,0.85)]">
<Button
aria-label="Close avatar editor"
className="absolute -right-3 -top-3 h-9 w-9 rounded-full"
data-testid="community-avatar-close"
onClick={() => setIsAvatarEditorOpen(false)}
size="icon"
type="button"
>
<X className="h-4 w-4" />
</Button>
<ProfileAvatarEditor
avatarUrl={avatarUrl}
disabled={isPending}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onDone={() => setIsAvatarEditorOpen(false)}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={setAvatarUrl}
previewName={displayName.trim() || "Your profile"}
testIdPrefix="community-avatar"
/>
</div>
</div>
</>
) : isProfileStage ? (
isAvatarEditorOpen ? (
<div className="relative rounded-3xl bg-white/85 px-6 py-8 shadow-[0_0_80px_50px_rgba(255,255,255,0.85)]">
<Button
aria-label="Close avatar editor"
className="absolute -right-3 -top-3 h-9 w-9 rounded-full"
data-testid="community-avatar-close"
onClick={() => setIsAvatarEditorOpen(false)}
size="icon"
type="button"
>
<X className="h-4 w-4" />
</Button>
<ProfileAvatarEditor
avatarUrl={avatarUrl}
disabled={isPending}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onDone={() => setIsAvatarEditorOpen(false)}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={setAvatarUrl}
previewName={displayName.trim() || "Your profile"}
testIdPrefix="community-avatar"
/>
</div>
) : (
<>
<h1 className="text-title font-normal">Build your profile</h1>
<p className="mx-auto mt-3 max-w-[380px] text-sm leading-6 text-foreground/80">
Add a name and avatar. They’ll show up on your messages,
reactions, and agent handoffs.
</p>
<div className="mt-12">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
/>
</div>
<div className="mx-auto mt-8 w-full max-w-[300px] text-left">
<label
className="text-sm font-medium"
htmlFor="community-display-name"
>
Your name
</label>
<Input
aria-label="Community display name"
autoFocus
className="mt-1.5 h-10 rounded-full bg-white/90 px-4"
id="community-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="First and last name"
value={displayName}
/>
</div>
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
</p>
) : null}
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
disabled={
!displayName.trim() || isPending || isUploadingAvatar
}
onClick={() => void saveProfile()}
>
Continue
</Button>
</OnboardingFooter>
</>
)
) : (
<>
<h1 className="text-3xl font-semibold">Build your profile</h1>
<p className="mx-auto mt-3 max-w-[380px] text-sm leading-6 text-foreground/80">
Add a name and avatar. They’ll show up on your messages,
reactions, and agent handoffs.
<h1 className="text-title font-normal">Meet your starter team</h1>
<p className="mx-auto mt-3 max-w-[400px] text-sm leading-6 text-foreground/80">
Buzz lets you bring multiple agents into the same workspace.
This team will help you get started using Buzz.
</p>
<div className="mt-12">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
/>
</div>
<div className="mx-auto mt-8 w-full max-w-[300px] text-left">
<label
className="text-sm font-medium"
htmlFor="community-display-name"
>
Your name
</label>
<Input
aria-label="Community display name"
autoFocus
className="mt-1.5 h-10 rounded-full bg-white/90 px-4"
id="community-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="First and last name"
value={displayName}
/>
</div>
{starterPersonas.length > 0 ? (
<div className="mt-10 flex flex-wrap justify-center gap-8">
{starterPersonas.map((persona) => (
<div
className="flex w-36 flex-col items-center gap-3"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-28 w-28 text-3xl"
label={persona.displayName}
/>
<span className="font-mono text-xs font-medium uppercase tracking-[0.15em]">
{persona.displayName}
</span>
</div>
))}
</div>
) : null}
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
</p>
) : null}
<div className="mt-12 flex flex-col items-center gap-3">
<OnboardingFooter>
<Button
className="h-10 rounded-full px-8"
disabled={
!displayName.trim() || isPending || isUploadingAvatar
}
onClick={() => void saveProfile()}
>
Continue
</Button>
</div>
</>
)
) : (
<>
<h1 className="text-3xl font-semibold">Meet your starter team</h1>
<p className="mx-auto mt-3 max-w-[400px] text-sm leading-6 text-foreground/80">
Buzz lets you bring multiple agents into the same workspace. This
team will help you get started using Buzz.
</p>
{starterPersonas.length > 0 ? (
<div className="mt-10 flex flex-wrap justify-center gap-8">
{starterPersonas.map((persona) => (
<div
className="flex w-36 flex-col items-center gap-3"
key={persona.id}
>
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-28 w-28 text-3xl"
label={persona.displayName}
/>
<span className="font-mono text-xs font-medium uppercase tracking-[0.15em]">
{persona.displayName}
</span>
</div>
))}
</div>
) : null}
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
</p>
) : null}
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className="h-10 rounded-full px-6"
disabled={isPending}
onClick={() => void finalize()}
>
{transaction.stage === "finalizing"
? "Preparing Welcome…"
: `Enter ${transaction.communityName}`}
</Button>
{transaction.error ? (
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
className={ONBOARDING_PRIMARY_CTA_CLASS}
disabled={isPending}
onClick={() => void finish()}
variant="ghost"
onClick={() => void finalize()}
>
Skip for now
{transaction.stage === "finalizing"
? "Preparing Welcome…"
: `Enter ${transaction.communityName}`}
</Button>
) : null}
</div>
</>
)}
</div>
{transaction.error ? (
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
disabled={isPending}
onClick={() => void finish()}
variant="ghost"
>
Skip for now
</Button>
) : null}
</OnboardingFooter>
</>
)}
</div>
</OnboardingFooterProvider>
</div>
);
}

View File

@@ -14,6 +14,8 @@ import {
import type { GlobalAgentConfig } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -133,13 +135,15 @@ export function DefaultConfigStep({
}: DefaultConfigStepProps) {
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center"
// pb clears the always-docked footer: the config fields can overflow on
// short windows, so reserve room to scroll clear of the fixed CTA group.
className="flex w-full flex-col items-center pb-20"
data-testid="onboarding-page-config"
direction={direction}
transitionKey={`default-config-${direction}`}
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
<h1 className="text-title font-normal text-foreground">
Configure your default model settings
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
@@ -152,9 +156,9 @@ export function DefaultConfigStep({
<AgentDefaultsSection />
</div>
<div className="mt-10 flex flex-col items-center gap-3">
<OnboardingFooter>
<Button
className="h-10 rounded-full px-8"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-finish"
onClick={actions.complete}
type="button"
@@ -171,7 +175,7 @@ export function DefaultConfigStep({
>
Back
</Button>
</div>
</OnboardingFooter>
</OnboardingSlideTransition>
);
}

View File

@@ -12,8 +12,12 @@ import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import {
ONBOARDING_LANDING_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooterProvider } from "./OnboardingFooter";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { OnboardingStepDots } from "./OnboardingStepDots";
import { SetupStep } from "./SetupStep";
type MachinePage = "identity" | "key-import" | "backup" | "setup" | "config";
@@ -90,106 +94,115 @@ export function MachineOnboardingFlow({
return (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 text-foreground ${
page === "setup" || page === "config" ? "py-24" : "py-8"
} ${page === "identity" ? "buzz-onboarding-welcome" : ""}`}
page === "identity"
? "buzz-onboarding-welcome py-8"
: "pb-28 pt-[106px]"
}`}
data-testid="machine-onboarding-gate"
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" ? (
<OnboardingStepDots
<OnboardingChrome
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
) : null}
<div className="relative my-auto flex w-full max-w-[920px] flex-col items-center text-center">
{page === "identity" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction="forward"
effect="mask-reveal-up"
transitionKey="machine-identity"
>
<img
alt="Buzz"
className="w-full max-w-[560px]"
src="/landing/buzz-wordmark.png"
<OnboardingFooterProvider>
<div
className={`relative flex w-full max-w-[920px] flex-col items-center text-center ${
page === "identity" ? "my-auto" : ""
}`}
>
{page === "identity" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction="forward"
effect="mask-reveal-up"
transitionKey="machine-identity"
>
<img
alt="Buzz"
className="w-full max-w-[600px]"
src="/landing/buzz-wordmark.png"
/>
<p className="mt-2 max-w-[560px] text-center text-2xl font-normal leading-none text-foreground">
Your people, your agents, your projects —<br />
all in one place.
</p>
{error ? (
<p className="mt-4 text-sm text-destructive">{error}</p>
) : null}
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className={ONBOARDING_LANDING_CTA_CLASS}
disabled={isPending}
onClick={() => void loadFreshIdentity()}
type="button"
>
{isPending ? "Saving identity…" : "Get started"}
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
disabled={isPending}
onClick={() => setPage("key-import")}
type="button"
variant="ghost"
>
Enter a key
</Button>
</div>
</OnboardingSlideTransition>
) : page === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[640px] flex-col items-center text-center"
direction="forward"
transitionKey="machine-key-import"
>
<h1 className="text-title font-normal text-foreground">
{identityLost ? "Re-import your key" : "Enter your private key"}
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
{identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "If you already have a Nostr account, enter your private key below to get started."}
</p>
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : "Back"}
onBack={
identityLost
? () => void replaceLostIdentity()
: () => setPage("identity")
}
onImport={importExistingIdentity}
variant="spotlight"
/>
</OnboardingSlideTransition>
) : page === "backup" ? (
<BackupStep
direction="forward"
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
/>
<p className="mt-2 max-w-[560px] text-center text-2xl font-normal leading-none text-foreground">
Your people, your agents, your projects — all in one place.
</p>
{error ? (
<p className="mt-4 text-sm text-destructive">{error}</p>
) : null}
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className="h-10 rounded-full px-6"
disabled={isPending}
onClick={() => void loadFreshIdentity()}
type="button"
>
{isPending ? "Saving identity…" : "Get started"}
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
disabled={isPending}
onClick={() => setPage("key-import")}
type="button"
variant="ghost"
>
Enter a key
</Button>
</div>
</OnboardingSlideTransition>
) : page === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[640px] flex-col items-center text-center"
direction="forward"
transitionKey="machine-key-import"
>
<h1 className="text-3xl font-semibold tracking-tight">
{identityLost ? "Re-import your key" : "Enter your private key"}
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-foreground/80">
{identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "If you already have a Nostr account, enter your private key below to get started."}
</p>
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : "Back"}
onBack={
identityLost
? () => void replaceLostIdentity()
: () => setPage("identity")
}
onImport={importExistingIdentity}
variant="spotlight"
) : page === "setup" ? (
<SetupStep
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
next: () => setPage("config"),
}}
direction="forward"
/>
</OnboardingSlideTransition>
) : page === "backup" ? (
<BackupStep
direction="forward"
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
/>
) : page === "setup" ? (
<SetupStep
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
next: () => setPage("config"),
}}
direction="forward"
/>
) : (
<DefaultConfigStep
actions={{
back: () => setPage("setup"),
complete: () => complete(selectedPubkey ?? undefined),
}}
direction="forward"
/>
)}
</div>
) : (
<DefaultConfigStep
actions={{
back: () => setPage("setup"),
complete: () => complete(selectedPubkey ?? undefined),
}}
direction="forward"
/>
)}
</div>
</OnboardingFooterProvider>
</div>
);
}

View File

@@ -6,6 +6,8 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
@@ -87,6 +89,14 @@ export function NostrKeyImportForm({
}, []);
const handleSubmit = React.useCallback(async () => {
// Guard here, not just on the submit button: the button now lives in the
// portaled footer as type="button", so the single-field form still submits
// on Enter. Without this, pressing Enter during an in-flight import fires a
// second concurrent onImport (double keyring write).
if (isInteractionDisabled) {
return;
}
if (!previewNpub) {
setImportError(
"That doesn't look like a valid nsec. Paste an nsec1 key.",
@@ -106,7 +116,7 @@ export function NostrKeyImportForm({
} finally {
setIsImporting(false);
}
}, [onImport, previewNpub, trimmedInput]);
}, [isInteractionDisabled, onImport, previewNpub, trimmedInput]);
return (
<form
@@ -261,20 +271,21 @@ export function NostrKeyImportForm({
<p className="text-center text-sm text-destructive">{errorMessage}</p>
) : null}
<div
className={
variant === "spotlight"
? "mt-4 flex w-full flex-col items-center gap-3"
: "flex w-full flex-col gap-3 pt-1"
}
>
<OnboardingFooter>
<Button
className={
variant === "spotlight" ? "h-10 rounded-full px-8" : "h-10 w-full"
// Only the spotlight (onboarding) treatment gets the docked pill CTA.
// The default variant renders outside the onboarding footer provider
// (e.g. KeyringLockedScreen) and must stay full-width to match its
// sibling Back button.
variant === "spotlight"
? ONBOARDING_PRIMARY_CTA_CLASS
: "h-10 w-full"
}
data-testid="nostr-import-submit"
disabled={!isValid || isInteractionDisabled}
type="submit"
onClick={() => void handleSubmit()}
type="button"
>
{isImporting ? (
<Spinner aria-label="Importing key" className="h-4 w-4 border-2" />
@@ -298,7 +309,7 @@ export function NostrKeyImportForm({
>
{backLabel}
</Button>
</div>
</OnboardingFooter>
</form>
);
}

View File

@@ -44,31 +44,55 @@ export function NsecMaskedDisplay({
copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
}
const isBare = variant === "bare";
// Mask every character (no plaintext prefix leak), matching the real key's
// length so toggling reveal never reflows the monospace text (no layout shift).
// Bullets are joined with a zero-width space: WebKit (WKWebView) will not
// line-break a run of U+2022 even with break-all/overflow-wrap, so without an
// explicit break opportunity the masked key overflows its container. The mask
// is decorative and non-selectable, and copy uses the raw nsec, so the
// injected ZWSP is never surfaced.
const maskedNsec = React.useMemo(
() => Array.from(nsec, () => "•").join("\u200b"),
[nsec],
);
const iconSize = isBare ? "h-6 w-6" : "h-4 w-4";
return (
<div
className={
variant === "boxed"
? "overflow-hidden rounded-lg border border-border/70 bg-muted/30"
: ""
isBare
? ""
: "overflow-hidden rounded-lg border border-border/70 bg-muted/30"
}
>
<div className="flex items-center gap-2 px-3 py-2">
<p
className={`min-w-0 flex-1 break-all font-mono leading-5 ${
variant === "bare" ? "text-base" : "text-xs"
} ${
isRevealed
? "select-text text-foreground"
: "select-none text-muted-foreground blur-[4px]"
}`}
data-testid="nsec-value"
>
{isRevealed ? nsec : nsec.slice(0, 6) + "•".repeat(52)}
</p>
<div className="flex shrink-0 gap-1">
<div
className={`flex min-w-0 items-center ${isBare ? "gap-4" : "gap-2 px-3 py-2"}`}
>
{/* Wrapping element is a block inside the flex item, not the flex item
itself: WebKit (WKWebView) does not wrap a long unbroken string when
the text node is the flex child directly, even with break-all /
overflow-wrap. A plain block wraps reliably in every engine. */}
<div className="min-w-0 flex-1">
<p
className={`w-full break-all [overflow-wrap:anywhere] font-mono ${
isBare
? "text-nsec-key text-[var(--buzz-onboarding-backup-ink)]"
: "text-xs leading-5"
} ${
isRevealed
? `select-text ${isBare ? "" : "text-foreground"}`
: `select-none blur-[4px] ${isBare ? "" : "text-muted-foreground"}`
}`}
data-testid="nsec-value"
>
{isRevealed ? nsec : maskedNsec}
</p>
</div>
<div className={`flex shrink-0 ${isBare ? "gap-1.5" : "gap-1"}`}>
<Button
aria-label={isRevealed ? "Hide private key" : "Reveal private key"}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className={`${isBare ? "h-10 w-10" : "h-7 w-7"} text-muted-foreground hover:text-foreground`}
data-testid="nsec-reveal-toggle"
onClick={handleRevealToggle}
size="icon"
@@ -76,25 +100,27 @@ export function NsecMaskedDisplay({
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
<EyeOff className={iconSize} aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
<Eye className={iconSize} aria-hidden="true" />
)}
</Button>
<Button
aria-label="Copy private key"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className={`${isBare ? "h-10 w-10" : "h-7 w-7"} text-muted-foreground hover:text-foreground`}
data-testid="nsec-copy"
disabled={!isRevealed}
onClick={() => void handleCopy()}
size="icon"
type="button"
variant="ghost"
>
{isCopied ? (
<Check className="h-4 w-4 text-primary" aria-hidden="true" />
<Check
className={`${iconSize} text-primary`}
aria-hidden="true"
/>
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
<Copy className={iconSize} aria-hidden="true" />
)}
</Button>
</div>

View File

@@ -0,0 +1,65 @@
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, harness setup,
* default config, community choice, community profile, meet the team. Used as
* the default pagination length when a flow doesn't pass an explicit total.
*/
export const TOTAL_ONBOARDING_PAGES = 7;
/** Shared pill shape (38px tall) for every onboarding primary CTA. */
const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
/**
* Primary-CTA styling for the in-step onboarding pages: the shared pill with a
* light-blue label (`--buzz-onboarding-cta-label`, available on any
* `.buzz-onboarding-neutral-theme` subtree).
*/
export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`;
/**
* Primary-CTA styling for the landing screen only: the shared pill with the
* chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved
* for the CTAs inside the steps.
*/
export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
/**
* Shared onboarding chrome shown on every page after the landing screen: a
* static Buzz mark pinned to the top-left, and a centered pagination track that
* sits above the page title. The active page reads as a longer bar; inactive
* pages are dots.
*/
export function OnboardingChrome({
current,
total = TOTAL_ONBOARDING_PAGES,
}: {
current: number;
total?: number;
}) {
return (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-14 z-10 flex items-center px-6 text-foreground"
>
<span className="block w-11" data-testid="onboarding-logo">
<BuzzMark className="h-auto w-full" />
</span>
<div
className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2"
data-testid="onboarding-step-dots"
>
{Array.from({ length: total }, (_, i) => i + 1).map((position) => (
<span
className={
position === current
? "block h-1.5 w-7 rounded-full bg-foreground"
: "block h-1.5 w-1.5 rounded-full bg-foreground/30"
}
key={position}
/>
))}
</div>
</div>
);
}

View File

@@ -15,8 +15,9 @@ import {
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
import { AvatarStep } from "./AvatarStep";
import { OnboardingChrome } from "./OnboardingChrome";
import { OnboardingFooterProvider } from "./OnboardingFooter";
import { MembershipDenied } from "./MembershipDenied";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -376,7 +377,6 @@ export function OnboardingFlow({
const pageIndex = activeSteps.indexOf(normalizedPage);
const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET;
const totalOnboardingSteps = activeSteps.length;
const hideFixedProgressOnCompact = currentPage === "avatar";
// Swapping the identity changes the pubkey, which remounts this flow
// (keyed on pubkey in App.tsx) and re-runs the onboarding gate: the new
@@ -447,154 +447,136 @@ export function OnboardingFlow({
return (
<>
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-start justify-center overflow-y-auto bg-background px-4 py-8 text-foreground"
className="buzz-onboarding-neutral-theme buzz-startup-shell flex items-start justify-center overflow-y-auto bg-background px-4 pb-28 pt-[106px] text-foreground"
data-testid="onboarding-gate"
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<div
className={`relative my-auto flex w-full flex-col items-center text-center ${
currentPage === "avatar" ? "max-w-[1080px]" : "max-w-[500px]"
}`}
>
<OnboardingSlideTransition
className="w-auto"
containerClassName={`fixed bottom-12 left-1/2 z-40 w-auto -translate-x-1/2 ${
hideFixedProgressOnCompact ? "max-lg:hidden" : ""
}`}
direction={transitionDirection}
transitionKey={`progress-${currentStep}-${transitionDirection}-${
hideFixedProgressOnCompact ? "compact-hidden" : "visible"
<OnboardingChrome current={currentStep} total={totalOnboardingSteps} />
<OnboardingFooterProvider>
<div
className={`relative flex w-full flex-col items-center text-center ${
currentPage === "avatar" ? "max-w-[1080px]" : "max-w-[500px]"
}`}
>
<StepProgress
activeSegmentClassName="bg-primary"
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalOnboardingSteps}
/>
</OnboardingSlideTransition>
{membershipError &&
(currentPage === "profile" || currentPage === "avatar") ? (
<div className="mb-4 w-full max-w-[500px] rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm">
{membershipError.kind === "unreachable" ? (
<>
<p className="font-medium text-destructive">
Can't reach this relay
</p>
<p className="mt-1 text-muted-foreground">
Check your connection or change your community.
</p>
<Button
className="mt-3"
onClick={() => setIsCommunityChangeOpen(true)}
size="sm"
variant="outline"
>
Change community
</Button>
</>
) : (
<>
<p className="font-medium text-destructive">
{membershipError.message ?? "Something went wrong"}
</p>
<p className="mt-1 text-muted-foreground">
The relay returned an error. Try again.
</p>
</>
)}
</div>
) : null}
{currentPage === "profile" ? (
<ProfileStep
actions={{
advanceWithoutSaving: advanceFromProfileWithoutSaving,
back: () => {
setMembershipError(null);
setIsCommunityChangeOpen(true);
},
clearAvatarDraft: resetAvatarDraft,
importExistingKey: showKeyImportPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("avatar");
},
updateAvatarUrl: updateAvatarUrlDraft,
updateDisplayName: updateDisplayNameDraft,
}}
direction={transitionDirection}
state={profileStepState}
/>
) : currentPage === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`key-import-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
{identityLost ? (
{membershipError &&
(currentPage === "profile" || currentPage === "avatar") ? (
<div className="mb-4 w-full max-w-[500px] rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm">
{membershipError.kind === "unreachable" ? (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Re-import your key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Your identity is no longer in the system keyring.
Re-import your nsec to restore it — Buzz will restart to
finish recovery. Or go back to start a new identity with a
fresh key.
<p className="font-medium text-destructive">
Can't reach this relay
</p>
<p className="mt-1 text-muted-foreground">
Check your connection or change your community.
</p>
<Button
className="mt-3"
onClick={() => setIsCommunityChangeOpen(true)}
size="sm"
variant="outline"
>
Change community
</Button>
</>
) : (
<>
<h1 className="text-3xl font-semibold tracking-tight">
Use your existing key
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Import your Nostr private key to use that identity with
Buzz. If this key already has a profile on the relay, your
name and avatar are restored automatically.
<p className="font-medium text-destructive">
{membershipError.message ?? "Something went wrong"}
</p>
<p className="mt-1 text-muted-foreground">
The relay returned an error. Try again.
</p>
</>
)}
</div>
) : null}
{persistError ? (
<p className="mt-4 w-full max-w-[440px] text-sm text-destructive">
{persistError}
</p>
) : null}
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : undefined}
onBack={identityLost ? handleLostModeBack : showProfilePage}
onImport={importExistingKey}
{currentPage === "profile" ? (
<ProfileStep
actions={{
advanceWithoutSaving: advanceFromProfileWithoutSaving,
back: () => {
setMembershipError(null);
setIsCommunityChangeOpen(true);
},
clearAvatarDraft: resetAvatarDraft,
importExistingKey: showKeyImportPage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("avatar");
},
updateAvatarUrl: updateAvatarUrlDraft,
updateDisplayName: updateDisplayNameDraft,
}}
direction={transitionDirection}
state={profileStepState}
/>
</OnboardingSlideTransition>
) : (
<AvatarStep
actions={{
advanceWithoutSaving: complete,
back: showProfilePage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("complete");
},
updateAvatarUrl: updateAvatarUrlDraft,
}}
currentStep={currentStep}
direction={transitionDirection}
showAlwaysSkip={true}
state={avatarStepState}
totalSteps={totalOnboardingSteps}
/>
)}
</div>
) : currentPage === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`key-import-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
{identityLost ? (
<>
<h1 className="text-title font-normal text-foreground">
Re-import your key
</h1>
<p className="mt-5 text-sm leading-6 text-muted-foreground">
Your identity is no longer in the system keyring.
Re-import your nsec to restore it — Buzz will restart to
finish recovery. Or go back to start a new identity with
a fresh key.
</p>
</>
) : (
<>
<h1 className="text-title font-normal text-foreground">
Use your existing key
</h1>
<p className="mt-5 text-sm leading-6 text-muted-foreground">
Import your Nostr private key to use that identity with
Buzz. If this key already has a profile on the relay,
your name and avatar are restored automatically.
</p>
</>
)}
</div>
{persistError ? (
<p className="mt-4 w-full max-w-[440px] text-sm text-destructive">
{persistError}
</p>
) : null}
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : undefined}
onBack={identityLost ? handleLostModeBack : showProfilePage}
onImport={importExistingKey}
/>
</OnboardingSlideTransition>
) : (
<AvatarStep
actions={{
advanceWithoutSaving: complete,
back: showProfilePage,
onUploadingChange: setIsUploadingAvatar,
skipForNow,
submit: () => {
void saveProfileAndContinue("complete");
},
updateAvatarUrl: updateAvatarUrlDraft,
}}
direction={transitionDirection}
showAlwaysSkip={true}
state={avatarStepState}
/>
)}
</div>
</OnboardingFooterProvider>
</div>
{isCommunityChangeOpen ? (
<CommunityChangeOverlay

View File

@@ -0,0 +1,84 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/shared/lib/cn";
const OnboardingFooterTargetContext = React.createContext<HTMLElement | null>(
null,
);
/**
* Renders the shared, bottom-docked CTA slot for an onboarding shell and
* exposes it to descendant steps via context.
*
* The slot is a direct child of the shell — a sibling of the animated step
* content — so CTAs portaled into it through `OnboardingFooter` escape
* `OnboardingSlideTransition`'s transform. A transformed ancestor establishes a
* containing block that would otherwise trap `position: fixed`, which is why
* the CTAs can't simply live inside the step and use `fixed` themselves. The
* slot stays inside the `.buzz-onboarding-neutral-theme` subtree so
* `--buzz-welcome-chartreuse` and the theme color tokens still resolve for the
* docked buttons.
*/
export function OnboardingFooterProvider({
children,
}: {
children: React.ReactNode;
}) {
const [target, setTarget] = React.useState<HTMLElement | null>(null);
return (
<OnboardingFooterTargetContext.Provider value={target}>
{children}
{/* Scrim: on pages taller than the viewport, content scrolls under the
docked CTA. This bottom-anchored fade to the shell's bottom color
(invisible on short pages and on the flat chartreuse landing) gives
the CTA a floor to sit on instead of colliding with form fields. */}
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 bottom-0 z-10 h-36 bg-[linear-gradient(to_top,var(--buzz-onboarding-shell-bottom)_35%,transparent)]"
/>
<div
className="pointer-events-none fixed inset-x-0 bottom-5 z-20 flex justify-center px-4"
data-testid="onboarding-footer-slot"
ref={setTarget}
/>
</OnboardingFooterTargetContext.Provider>
);
}
/**
* Portals a step's primary CTA group into the shell's bottom-docked footer slot
* (see `OnboardingFooterProvider`). When no slot exists in context — screens
* that render onboarding steps outside a provider, e.g. `KeyringLockedScreen` —
* it falls back to rendering the CTA group inline so the buttons never vanish.
*/
export function OnboardingFooter({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const target = React.useContext(OnboardingFooterTargetContext);
const group = (
<div
className={cn(
"flex w-full max-w-[500px] flex-col items-center gap-3",
// The docked slot is click-through (`pointer-events-none`); re-enable
// pointer events on the CTA group itself. Inline (no slot) needs no
// override since it sits in normal flow.
target && "pointer-events-auto",
className,
)}
>
{children}
</div>
);
if (!target) {
return group;
}
return createPortal(group, target);
}

View File

@@ -1,40 +0,0 @@
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, harness setup,
* default config, community choice, community profile, meet the team.
*/
export const TOTAL_ONBOARDING_PAGES = 7;
/**
* Top-left paging indicator for the first-launch flow. The bee marks the
* current page on a dot track; shown on every page after the landing screen.
*/
export function OnboardingStepDots({
current,
total = TOTAL_ONBOARDING_PAGES,
}: {
current: number;
total?: number;
}) {
return (
<div
aria-hidden
className="pointer-events-none fixed left-6 top-12 z-10 flex items-center gap-2.5 text-foreground"
data-testid="onboarding-step-dots"
>
{Array.from({ length: total }, (_, i) => i + 1).map((position) =>
position === current ? (
<span className="block w-9" key={position}>
<BuzzMark className="h-auto w-full" />
</span>
) : (
<span
className="block h-[7px] w-[7px] rounded-full bg-foreground"
key={position}
/>
),
)}
</div>
);
}

View File

@@ -8,6 +8,8 @@ import { cn } from "@/shared/lib/cn";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
type OnboardingTransitionEffect,
@@ -216,10 +218,10 @@ export function ProfileStep({
transitionKey={`profile-${direction}`}
>
<div className="w-full max-w-2xl">
<h1 className="text-3xl font-semibold text-foreground">
<h1 className="text-title font-normal text-foreground">
What should we call you?
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
<p className="mt-5 text-sm leading-6 text-muted-foreground">
Pick the name people and agents will see in Buzz. You can change it
anytime.
</p>
@@ -275,9 +277,9 @@ export function ProfileStep({
<ErrorBanner isSaving={isSaving} message={saveRecovery.errorMessage} />
) : null}
<div className="mt-12 flex w-full max-w-[500px] flex-col gap-3">
<OnboardingFooter>
<Button
className="h-10 w-full"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={!canSubmit}
onClick={submit}
@@ -340,7 +342,7 @@ export function ProfileStep({
) : null}
<div className="flex-1" />
</div>
</div>
</OnboardingFooter>
</OnboardingSlideTransition>
);
}

View File

@@ -23,6 +23,8 @@ import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -457,7 +459,7 @@ function RuntimeProvidersSection({
return (
<section className="flex w-full flex-col items-center gap-8">
<div className="w-full max-w-[520px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
<h1 className="text-title font-normal text-foreground">
Use the models that fit the task
</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
@@ -516,16 +518,18 @@ function SetupStepContent({
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center"
// pb clears the always-docked footer: the provider list can overflow on
// short windows, so reserve room to scroll clear of the fixed CTA group.
className="flex w-full flex-col items-center pb-20"
data-testid="onboarding-page-2"
direction={direction}
transitionKey={`setup-${direction}`}
>
<RuntimeProvidersSection runtimeProviders={runtimeProviders} />
<div className="mt-10 flex flex-col items-center gap-3">
<OnboardingFooter>
<Button
className="h-10 rounded-full px-8"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-setup-next"
onClick={actions.next}
type="button"
@@ -542,7 +546,7 @@ function SetupStepContent({
>
Back
</Button>
</div>
</OnboardingFooter>
</OnboardingSlideTransition>
);
}

View File

@@ -215,15 +215,46 @@
}
.buzz-onboarding-neutral-theme.buzz-startup-shell {
background-image: linear-gradient(to bottom, #d7d72e, #d7e7f6);
/* Dot grid over the chartreuse→light-blue gradient (dots layer first so
they sit on top). The welcome page overrides this with dots over a flat
chartreuse fill below. */
background-image:
radial-gradient(circle, rgb(0 0 0 / 0.08) 1px, transparent 1px),
linear-gradient(
to bottom,
var(--buzz-welcome-chartreuse),
var(--buzz-onboarding-shell-bottom)
);
background-size:
24px 24px,
auto;
}
.buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-welcome {
background-color: #d7d72e;
background-image: none;
background-color: var(--buzz-welcome-chartreuse);
/* Landing is a flat chartreuse field, so the docked-footer scrim must match
it (not the gradient's light-blue bottom) to stay invisible. */
--buzz-onboarding-shell-bottom: var(--buzz-welcome-chartreuse);
/* Subtle dot grid: 2px dots spaced on a 24px lattice. */
background-image: radial-gradient(
circle,
rgb(0 0 0 / 0.08) 1px,
transparent 1px
);
background-size: 24px 24px;
}
.buzz-onboarding-neutral-theme {
/* Landing "chartreuse" — the welcome background; reused for the CTA label. */
--buzz-welcome-chartreuse: #d7d72e;
/* Bottom stop of the onboarding shell gradient; also the docked-footer
scrim color so content scrolling under the CTA fades into the page. */
--buzz-onboarding-shell-bottom: #d7e7f6;
/* Label color for the primary onboarding CTA (light blue on the dark pill). */
--buzz-onboarding-cta-label: #d7e6f0;
/* Olive ink for the backup step's private key and its "never share" caption,
reading against the fuzzy-white key frame. */
--buzz-onboarding-backup-ink: #717106;
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--primary: 0 0% 9%;
@@ -328,8 +359,13 @@
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
/* `backwards` (not `both`): the reveal ends on an identity transform, so
holding it forward is visually a no-op but leaves a lingering transform
that establishes a containing block and traps `position: fixed`
descendants (the bottom-docked onboarding footer). Reverting to no
transform at rest looks identical and frees fixed positioning. */
animation: buzz-onboarding-mask-reveal-up 760ms
cubic-bezier(0.22, 1, 0.36, 1) both;
cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
transform-origin: 50% 70%;
}

View File

@@ -10,6 +10,10 @@ export default {
fontSize: {
"2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges)
"3xs": "0.5rem", // 8px — tiny glyphs / micro labels
// 40px — onboarding page titles (tightened tracking for large display type)
title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }],
// 36px — the backup-step private key, shown large in monospace
"nsec-key": ["2.25rem", { lineHeight: "1.3" }],
},
borderRadius: {
lg: "var(--radius)",

View File

@@ -17,7 +17,8 @@ test("normal first launch uses the already-persisted identity", async ({
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await expect(gate).toHaveCSS("background-color", "rgb(215, 215, 46)");
await expect(gate).toHaveCSS("background-image", "none");
// Landing carries a subtle dot-grid pattern over the chartreuse fill.
await expect(gate).toHaveCSS("background-image", /radial-gradient/);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
await expect(page.getByRole("button", { name: "Get started" })).toHaveCSS(
"background-color",
@@ -30,9 +31,10 @@ test("normal first launch uses the already-persisted identity", async ({
name: "Your unique identity has been created",
}),
).toBeVisible();
// Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
await expect(gate).toHaveCSS(
"background-image",
"linear-gradient(rgb(215, 215, 46), rgb(215, 231, 246))",
/radial-gradient\(.*\), linear-gradient\(.*rgb\(215, 215, 46\).*rgb\(215, 231, 246\)\)/s,
);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
const commands = await page.evaluate(

View File

@@ -31,10 +31,11 @@ test("avatar step always shows Skip for now button without an error", async ({
await expect(skipBtn).toBeEnabled();
await expect(skipBtn).toHaveText("Skip for now");
// Capture a screenshot showing the avatar step with the skip button.
// Capture the whole viewport: the Skip/Next/Back CTAs are portaled into the
// docked footer (a sibling of the step subtree), so a section-scoped shot
// would omit the very buttons this artifact is meant to show.
await waitForAnimations(page);
const avatarSection = page.locator('[data-testid="onboarding-page-avatar"]');
await avatarSection.screenshot({
await page.screenshot({
path: `${SHOTS}/01-avatar-skip-button.png`,
});
});

View File

@@ -38,10 +38,10 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => {
await expect(revealBtn).toBeVisible();
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
// Take a screenshot of the masked state.
// Take a screenshot of the masked state. Capture the whole viewport: the CTAs
// are portaled into the docked footer outside the step subtree.
await waitForAnimations(page);
const backupSection = page.locator('[data-testid="onboarding-page-backup"]');
await backupSection.screenshot({
await page.screenshot({
path: `${SHOTS}/02-backup-step-masked.png`,
});
@@ -52,7 +52,7 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => {
// Take a screenshot of the revealed state.
await waitForAnimations(page);
await backupSection.screenshot({
await page.screenshot({
path: `${SHOTS}/03-backup-step-revealed.png`,
});
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { seedActiveIdentity } from "../helpers/onboarding";
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
const SHOT_DIR = "test-results/onboarding-docked-cta";
test.use({ viewport: { width: 1280, height: 800 } });
test("machine onboarding: landing, backup, setup docked CTAs", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` });
await page.getByRole("button", { name: "Get started" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity has been created",
}),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
// Reveal the key: box must not reflow (same-length monospace mask).
await page.getByTestId("nsec-reveal-toggle").click();
await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
await page.getByTestId("onboarding-next").click();
await expect(
page.getByRole("heading", { name: "Use the models that fit the task" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03-setup.png` });
});
test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
await page.getByTestId("onboarding-display-name").fill("Ada Lovelace");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/04-profile.png` });
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/onboarding-avatar.png");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/05-avatar.png` });
});