mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
65
desktop/src/features/onboarding/ui/OnboardingChrome.tsx
Normal file
65
desktop/src/features/onboarding/ui/OnboardingChrome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
84
desktop/src/features/onboarding/ui/OnboardingFooter.tsx
Normal file
84
desktop/src/features/onboarding/ui/OnboardingFooter.tsx
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
70
desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
Normal file
70
desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts
Normal 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` });
|
||||
});
|
||||
Reference in New Issue
Block a user