Expand onboarding V3 workshop preview

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-27 14:09:45 +01:00
parent 1aeab6d48a
commit 750dcfccc7
19 changed files with 1370 additions and 577 deletions

View File

@@ -64,6 +64,9 @@ type HostedCommunityOnboardingProps = {
* finished driving the flow.
*/
onReady?: () => void;
/** Use the production stage with local-only workshop behavior. */
previewMode?: boolean;
onPreviewContinue?: (communityName: string) => void;
/**
* While true, render only the sign-in modal and keep the page scaffolding
* hidden, so whatever screen launched the flow stays visible behind it.
@@ -74,8 +77,136 @@ type HostedCommunityOnboardingProps = {
export function HostedCommunityOnboarding({
onBack,
onReady,
onPreviewContinue,
previewMode = false,
stageHidden = false,
}: HostedCommunityOnboardingProps) {
if (previewMode) {
return (
<HostedCommunityOnboardingPreview
onBack={onBack}
onContinue={onPreviewContinue ?? (() => undefined)}
/>
);
}
return (
<HostedCommunityOnboardingLive
onBack={onBack}
onReady={onReady}
stageHidden={stageHidden}
/>
);
}
function HostedCommunityOnboardingPreview({
onBack,
onContinue,
}: {
onBack: () => void;
onContinue: (communityName: string) => void;
}) {
const [name, setName] = React.useState("");
const displayName = name || "your-community";
const composedAddressLength =
displayName.length + HOSTED_COMMUNITY_SUFFIX.length + 1;
const addressFontSize = `min(2.25rem, calc(90cqw / ${(
composedAddressLength * 0.62
).toFixed(2)}))`;
const continueToCommunity = (event: React.FormEvent) => {
event.preventDefault();
onContinue(name.trim() || "Your community");
};
return (
<div
className="flex min-h-[calc(100dvh-15.625rem)] w-full max-w-[920px] flex-col items-center text-center"
data-testid="hosted-community-preview"
>
<h1 className="max-w-[620px] text-title font-normal leading-[1.18] tracking-[-0.025em]">
Create a community
</h1>
<p className="mx-auto mt-2 max-w-[560px] text-sm leading-6 text-foreground">
Claim a Buzz address to get started.
</p>
<div className="flex w-full flex-1 flex-col justify-center text-left">
<Card
asChild
className={`${FUZZY_SURFACE_CLASS} !py-10 [--buzz-card-textured-min-height:176px]`}
data-testid="hosted-community-create-surface"
variant="textured"
>
<form
id="hosted-community-preview-create-form"
onSubmit={continueToCommunity}
>
<div
className="mx-auto flex w-full max-w-[900px] items-center justify-center whitespace-nowrap"
data-testid="hosted-community-address-line"
style={{ containerType: "inline-size" }}
>
<Input
aria-label="Community name"
autoComplete="off"
className="h-auto min-w-0 flex-none rounded-none border-0 bg-transparent p-0 text-right font-mono !text-[rgb(var(--buzz-hosted-community-surface-fg))] shadow-none placeholder:!text-[rgb(var(--buzz-hosted-community-surface-fg))] placeholder:opacity-20 focus-visible:ring-0"
data-testid="hosted-community-address-input"
id="hosted-community-preview-address"
maxLength={63}
onChange={(event) => setName(event.target.value.toLowerCase())}
placeholder="your-community"
spellCheck={false}
style={{
width: `${displayName.length}ch`,
fontSize: addressFontSize,
}}
value={name}
/>
<span
className="shrink-0 font-mono !text-[rgb(var(--buzz-hosted-community-surface-fg))]"
style={{ fontSize: addressFontSize }}
>
.{HOSTED_COMMUNITY_SUFFIX}
</span>
</div>
</form>
</Card>
<p
aria-hidden
className="relative z-10 mt-3 min-h-5 text-center text-sm invisible"
>
Community address status
</p>
</div>
<OnboardingFooter>
<Button
className={PAGE_CTA_CLASS}
data-testid="onboarding-preview-community-continue"
form="hosted-community-preview-create-form"
type="submit"
>
Next
</Button>
<Button
className={PAGE_BACK_CLASS}
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</div>
);
}
function HostedCommunityOnboardingLive({
onBack,
onReady,
stageHidden = false,
}: Pick<HostedCommunityOnboardingProps, "onBack" | "onReady" | "stageHidden">) {
const onboarding = useCommunityOnboarding();
const shouldReduceMotion = useReducedMotion();
const localPubkey = useIdentityQuery().data?.pubkey ?? null;

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveOnboardingPreviewMode } from "./onboardingPreview.ts";
import {
ONBOARDING_PREVIEW_JOURNEYS,
resolveOnboardingPreviewMode,
} from "./onboardingPreview.ts";
test("preview requires an exact development opt-in", () => {
assert.equal(
@@ -43,3 +46,31 @@ test("preview is available to the explicit E2E build", () => {
true,
);
});
test("today preserves the complete workshop journey", () => {
assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.today, {
afterAccount: "setup",
afterCommunityEntry: "community-connecting",
afterProfile: "starter-team",
communityChoiceBack: "config",
communityStep: 5,
finalStep: 7,
includeExistingCommunity: true,
profileStep: 6,
totalSteps: 7,
});
});
test("V3 skips technical setup and the transitional community screens", () => {
assert.deepEqual(ONBOARDING_PREVIEW_JOURNEYS.v3, {
afterAccount: "community-choice",
afterCommunityEntry: "community-profile",
afterProfile: "community-home",
communityChoiceBack: null,
communityStep: 3,
finalStep: 4,
includeExistingCommunity: false,
profileStep: 4,
totalSteps: 4,
});
});

View File

@@ -7,6 +7,68 @@ type OnboardingPreviewEnvironment = {
search: string;
};
export type OnboardingPreviewPage =
| "landing"
| "email"
| "identity-key"
| "sign-in"
| "sign-in-key"
| "forgot-password"
| "backup-options"
| "backup-password"
| "setup"
| "config"
| "community-choice"
| "community-entry"
| "community-connecting"
| "community-profile"
| "starter-team"
| "welcome-channel"
| "community-home";
export type OnboardingPreviewVariant = "today" | "v3";
type OnboardingPreviewJourney = {
afterAccount: OnboardingPreviewPage;
afterCommunityEntry: OnboardingPreviewPage;
afterProfile: OnboardingPreviewPage;
communityChoiceBack: OnboardingPreviewPage | null;
communityStep: number;
finalStep: number;
includeExistingCommunity: boolean;
profileStep: number;
totalSteps: number;
};
/** Workshop-only route maps that preserve today's full flow beside V3. */
export const ONBOARDING_PREVIEW_JOURNEYS: Record<
OnboardingPreviewVariant,
OnboardingPreviewJourney
> = {
today: {
afterAccount: "setup",
afterCommunityEntry: "community-connecting",
afterProfile: "starter-team",
communityChoiceBack: "config",
communityStep: 5,
finalStep: 7,
includeExistingCommunity: true,
profileStep: 6,
totalSteps: 7,
},
v3: {
afterAccount: "community-choice",
afterCommunityEntry: "community-profile",
afterProfile: "community-home",
communityChoiceBack: null,
communityStep: 3,
finalStep: 4,
includeExistingCommunity: false,
profileStep: 4,
totalSteps: 4,
},
};
/** Resolve the explicit, non-production onboarding workshop route. */
export function resolveOnboardingPreviewMode({
dev,

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Plus, Users } from "lucide-react";
import { Users } from "lucide-react";
import {
markCommunityOnboardingComplete,
@@ -17,10 +17,6 @@ import { useAvatarPresentation } from "@/features/profile/avatarPresentationStor
import { registerAvatarWhenReady } from "@/features/profile/avatarProfileSync";
import { profileQueryKey } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
parseEmojiAvatarDataUrl,
ProfileAvatarEditor,
} from "@/features/profile/ui/ProfileAvatarEditor";
import { getProfile, updateProfile } from "@/shared/api/tauriProfiles";
import { getIdentity, importIdentity } from "@/shared/api/tauriIdentity";
import { listPersonas } from "@/shared/api/tauriPersonas";
@@ -29,8 +25,6 @@ import type { AgentPersona } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { MembershipDenied } from "./MembershipDenied";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import {
@@ -42,6 +36,7 @@ import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { CommunityProfileStage } from "./CommunityProfileStage";
function isRelayMembershipDeniedError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
@@ -67,64 +62,6 @@ const ENTERING_CURTAIN_FADE_MS = 500;
*/
const ENTERING_CURTAIN_MAX_WAIT_MS = 8_000;
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
"--buzz-emoji-picker-rgb-background":
"var(--buzz-onboarding-emoji-picker-background)",
"--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)",
"--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)",
} as React.CSSProperties;
function AvatarCircle({
avatarUrl,
onClick,
previewName,
triggerRef,
}: {
avatarUrl: string;
onClick: () => void;
previewName: string;
triggerRef?: React.Ref<HTMLButtonElement>;
}) {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
const presentation = useAvatarPresentation(avatarUrl);
const hasAvatar =
avatarUrl.trim().length > 0 && presentation?.state !== "failed";
return (
<button
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
className="group block shrink-0 rounded-full"
data-testid="community-avatar-open"
onClick={onClick}
ref={triggerRef}
type="button"
>
{emojiAvatar ? (
<span
className="flex h-36 w-36 items-center justify-center overflow-hidden rounded-full text-5xl shadow-xs"
style={{ backgroundColor: emojiAvatar.color }}
>
{emojiAvatar.emoji}
</span>
) : hasAvatar ? (
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-36 w-36 rounded-full text-4xl"
label={previewName}
testId="community-avatar-circle"
/>
) : (
<span
className="flex h-36 w-36 items-center justify-center rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/40"
data-testid="community-avatar-empty"
>
<Plus className="h-7 w-7" aria-hidden="true" />
</span>
)}
</button>
);
}
function LoadingDots({ label }: { label: string }) {
return (
<span
@@ -157,22 +94,10 @@ export function CommunityOnboardingFlow({
const systemColorScheme = useSystemColorScheme();
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [localAvatarPreviewUrl, setLocalAvatarPreviewUrl] = React.useState<
string | null
>(null);
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
const [transitionDirection, setTransitionDirection] =
React.useState<OnboardingTransitionDirection>("forward");
const avatarPresentation = useAvatarPresentation(avatarUrl);
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
const [animatedPreviewEl, setAnimatedPreviewEl] =
React.useState<HTMLDivElement | null>(null);
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
React.useState(false);
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
string | null
>(null);
const [starterPersonas, setStarterPersonas] = React.useState<AgentPersona[]>(
[],
);
@@ -185,14 +110,6 @@ export function CommunityOnboardingFlow({
const [isCommunityChangeOpen, setIsCommunityChangeOpen] =
React.useState(false);
const [isCurtainFading, setIsCurtainFading] = React.useState(false);
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
const avatarTriggerRef = React.useRef<HTMLButtonElement | null>(null);
const avatarEditorContentRef = React.useRef<HTMLDivElement | null>(null);
const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] =
React.useState<number | null>(null);
const animateEmojiAvatarChange = React.useCallback(() => {
setAvatarSquishKey((key) => key + 1);
}, []);
// Also fetch on "entering": the curtain is a fresh mount of this component,
// so the team-intro fetch from the pre-curtain instance isn't in this state.
@@ -351,31 +268,6 @@ export function CommunityOnboardingFlow({
});
}, [isProfileStage]);
React.useLayoutEffect(() => {
if (isProfileStage && !isAvatarEditorOpen) {
nameInputRef.current?.focus();
}
}, [isAvatarEditorOpen, isProfileStage]);
React.useLayoutEffect(() => {
if (!isAvatarEditorOpen) {
setAvatarEditorDialogHeight(null);
return;
}
const content = avatarEditorContentRef.current;
if (!content) return;
const updateHeight = () => {
setAvatarEditorDialogHeight(content.getBoundingClientRect().height + 64);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(content);
return () => resizeObserver.disconnect();
}, [isAvatarEditorOpen]);
if (!transaction) return null;
if (isMembershipDenied) {
@@ -551,200 +443,17 @@ export function CommunityOnboardingFlow({
</div>
</>
) : isProfileStage ? (
<>
<div
className={cn(
"flex min-h-0 w-full flex-1 flex-col transition-[filter,opacity] duration-200 ease-out",
isAvatarEditorOpen &&
"pointer-events-none opacity-45 blur-[3px]",
)}
data-testid="community-profile-main"
>
<div className="shrink-0">
<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>
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center pt-8">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
triggerRef={avatarTriggerRef}
/>
<label
className="mt-7 block w-full max-w-[412px] text-left"
htmlFor="community-display-name"
>
<span className="mb-2 block pl-4 text-sm text-foreground">
Your username
</span>
<Input
aria-label="Community username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
className="h-14 rounded-2xl border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.28)] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg)/0.95)] px-5 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.5)] md:text-sm"
data-testid="community-profile-name-key"
disabled={isPending || isUploadingAvatar}
id="community-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Enter your username here"
ref={nameInputRef}
spellCheck={false}
type="text"
value={displayName}
/>
</label>
</div>
{transaction.error ? (
<p className="mt-4 text-sm text-destructive">
{transaction.error}
</p>
) : null}
</div>
<OnboardingFooter
className={cn(
"transition-[filter,opacity] duration-200 ease-out",
isAvatarEditorOpen &&
"pointer-events-none opacity-45 blur-[3px]",
)}
>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} w-20`}
data-testid="community-profile-next"
disabled={
!displayName.trim() || isPending || isUploadingAvatar
}
onClick={() => void saveProfile()}
type="button"
>
Next
</Button>
</OnboardingFooter>
<Dialog
onOpenChange={(open) => setIsAvatarEditorOpen(open)}
open={isAvatarEditorOpen}
>
<DialogContent
className="buzz-onboarding-neutral-theme w-[min(calc(100vw-2rem),920px)] max-w-[920px] gap-0 overflow-hidden rounded-[18px] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg))] px-8 pb-6 pt-10 text-sm text-foreground shadow-[0_28px_90px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.28),0_8px_28px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.18)] transition-[height] duration-[250ms] ease-out"
closeButtonClassName="right-6 top-6 h-10 w-10 rounded-full bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] text-[rgb(var(--buzz-onboarding-avatar-action-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-action-bg)/0.9)] hover:text-[rgb(var(--buzz-onboarding-avatar-action-fg))]"
data-system-color-scheme="light"
data-testid="community-avatar-editor-key-frame"
onCloseAutoFocus={(event) => {
event.preventDefault();
avatarTriggerRef.current?.focus();
}}
overlayVariant="transparent"
style={
avatarEditorDialogHeight === null
? undefined
: { height: avatarEditorDialogHeight }
}
>
<DialogTitle className="sr-only">
Edit your avatar
</DialogTitle>
<div
className="grid items-center gap-8 md:grid-cols-[240px_minmax(0,1fr)]"
ref={avatarEditorContentRef}
>
<div
className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 py-8"
data-testid="community-avatar-live-preview-panel"
>
<div className="relative h-48 w-48">
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="community-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{isAnimatedPreviewActive
? null
: (() => {
if (localAvatarPreviewUrl) {
return (
<ProfileAvatar
avatarUrl={localAvatarPreviewUrl}
className="h-full w-full rounded-full text-5xl"
label={
displayName.trim() || "Your profile"
}
testId="community-avatar-live-preview"
/>
);
}
const emojiAvatar =
parseEmojiAvatarDataUrl(avatarUrl);
return emojiAvatar ? (
<div
aria-label={`${displayName.trim() || "Your profile"} avatar`}
className="flex h-full w-full items-center justify-center overflow-hidden rounded-full text-6xl shadow-xs"
data-testid="community-avatar-live-preview"
role="img"
style={{
backgroundColor: emojiAvatar.color,
}}
>
<span
className={cn(
avatarSquishKey > 0 &&
"buzz-avatar-squish",
)}
data-testid="community-avatar-live-preview-emoji"
key={avatarSquishKey}
>
{emojiAvatar.emoji}
</span>
</div>
) : (
<ProfileAvatar
avatarUrl={
localAvatarPreviewUrl || avatarUrl || null
}
className="h-full w-full rounded-full text-5xl"
label={displayName.trim() || "Your profile"}
testId="community-avatar-live-preview"
/>
);
})()}
</div>
{animatedPreviewCaption ? (
<p className="text-center text-sm text-muted-foreground">
{animatedPreviewCaption}
</p>
) : null}
</div>
<ProfileAvatarEditor
animatedPreviewContainer={animatedPreviewEl}
avatarUrl={avatarUrl}
disabled={isPending}
donePending={isUploadingAvatar}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onDone={() => setIsAvatarEditorOpen(false)}
onAnimatedPreviewActiveChange={
setIsAnimatedPreviewActive
}
onAnimatedPreviewCaptionChange={
setAnimatedPreviewCaption
}
onEmojiAvatarChange={animateEmojiAvatarChange}
onLocalPreviewChange={setLocalAvatarPreviewUrl}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={setAvatarUrl}
presentation="onboarding-modal"
previewName={displayName.trim() || "Your profile"}
testIdPrefix="community-avatar"
/>
</div>
</DialogContent>
</Dialog>
</>
<CommunityProfileStage
avatarUrl={avatarUrl}
displayName={displayName}
errorMessage={transaction.error}
isPending={isPending}
isUploadingAvatar={isUploadingAvatar}
onAvatarUrlChange={setAvatarUrl}
onDisplayNameChange={setDisplayName}
onNext={() => void saveProfile()}
onUploadingChange={setIsUploadingAvatar}
/>
) : (
<>
<h1 className="text-title font-normal">

View File

@@ -0,0 +1,352 @@
import { Plus } from "lucide-react";
import * as React from "react";
import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
emojiAvatarDataUrl,
parseEmojiAvatarDataUrl,
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
import { ProfileAvatarEditor } from "@/features/profile/ui/ProfileAvatarEditor";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
const NEUTRAL_EMOJI_PICKER_THEME_VARS = {
"--buzz-emoji-picker-rgb-background":
"var(--buzz-onboarding-emoji-picker-background)",
"--buzz-emoji-picker-rgb-color": "var(--buzz-onboarding-emoji-picker-color)",
"--buzz-emoji-picker-rgb-input": "var(--buzz-onboarding-emoji-picker-input)",
} as React.CSSProperties;
const PREVIEW_URL_AVATAR = emojiAvatarDataUrl("🐝", "#D7D72E");
function blobAsDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error("Read failed."));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(blob);
});
}
const processPreviewImage = (file: File) => blobAsDataUrl(file);
const processPreviewAnimatedAvatar: AnimatedAvatarRecordingProcessor = ({
posterBytes,
}) =>
blobAsDataUrl(
new Blob([Uint8Array.from(posterBytes).buffer], { type: "image/png" }),
);
function AvatarCircle({
avatarUrl,
onClick,
previewName,
triggerRef,
}: {
avatarUrl: string;
onClick: () => void;
previewName: string;
triggerRef?: React.Ref<HTMLButtonElement>;
}) {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
const presentation = useAvatarPresentation(avatarUrl);
const hasAvatar =
avatarUrl.trim().length > 0 && presentation?.state !== "failed";
return (
<button
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
className="group block shrink-0 rounded-full"
data-testid="community-avatar-open"
onClick={onClick}
ref={triggerRef}
type="button"
>
{emojiAvatar ? (
<span
className="flex h-36 w-36 items-center justify-center overflow-hidden rounded-full text-5xl shadow-xs"
style={{ backgroundColor: emojiAvatar.color }}
>
{emojiAvatar.emoji}
</span>
) : hasAvatar ? (
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-36 w-36 rounded-full text-4xl"
label={previewName}
testId="community-avatar-circle"
/>
) : (
<span
className="flex h-36 w-36 items-center justify-center rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] transition-colors group-hover:bg-white/40"
data-testid="community-avatar-empty"
>
<Plus className="h-7 w-7" aria-hidden="true" />
</span>
)}
</button>
);
}
export function CommunityProfileStage({
avatarUrl,
displayName,
errorMessage,
isPending,
isUploadingAvatar,
onAvatarUrlChange,
onDisplayNameChange,
onNext,
onUploadingChange,
previewMode = false,
}: {
avatarUrl: string;
displayName: string;
errorMessage?: string;
isPending: boolean;
isUploadingAvatar: boolean;
onAvatarUrlChange: (value: string) => void;
onDisplayNameChange: (value: string) => void;
onNext: () => void;
onUploadingChange: (isUploading: boolean) => void;
previewMode?: boolean;
}) {
const [localAvatarPreviewUrl, setLocalAvatarPreviewUrl] = React.useState<
string | null
>(null);
const [avatarSquishKey, setAvatarSquishKey] = React.useState(0);
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
const [animatedPreviewEl, setAnimatedPreviewEl] =
React.useState<HTMLDivElement | null>(null);
const [isAnimatedPreviewActive, setIsAnimatedPreviewActive] =
React.useState(false);
const [animatedPreviewCaption, setAnimatedPreviewCaption] = React.useState<
string | null
>(null);
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
const avatarTriggerRef = React.useRef<HTMLButtonElement | null>(null);
const avatarEditorContentRef = React.useRef<HTMLDivElement | null>(null);
const [avatarEditorDialogHeight, setAvatarEditorDialogHeight] =
React.useState<number | null>(null);
const animateEmojiAvatarChange = React.useCallback(() => {
setAvatarSquishKey((key) => key + 1);
}, []);
const updateAvatarUrl = React.useCallback(
(nextAvatarUrl: string) => {
if (
!previewMode ||
nextAvatarUrl.length === 0 ||
nextAvatarUrl.startsWith("blob:") ||
nextAvatarUrl.startsWith("data:image/")
) {
onAvatarUrlChange(nextAvatarUrl);
return;
}
onAvatarUrlChange(PREVIEW_URL_AVATAR);
},
[onAvatarUrlChange, previewMode],
);
React.useLayoutEffect(() => {
if (!isAvatarEditorOpen) nameInputRef.current?.focus();
}, [isAvatarEditorOpen]);
React.useLayoutEffect(() => {
if (!isAvatarEditorOpen) {
setAvatarEditorDialogHeight(null);
return;
}
const content = avatarEditorContentRef.current;
if (!content) return;
const updateHeight = () => {
setAvatarEditorDialogHeight(content.getBoundingClientRect().height + 64);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(content);
return () => resizeObserver.disconnect();
}, [isAvatarEditorOpen]);
return (
<>
<div
className={cn(
"flex min-h-0 w-full flex-1 flex-col transition-[filter,opacity] duration-200 ease-out",
isAvatarEditorOpen && "pointer-events-none opacity-45 blur-[3px]",
)}
data-testid="community-profile-main"
>
<div className="shrink-0">
<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>
<div className="flex min-h-0 w-full flex-1 flex-col items-center justify-center pt-8">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
triggerRef={avatarTriggerRef}
/>
<label
className="mt-7 block w-full max-w-[412px] text-left"
htmlFor="community-display-name"
>
<span className="mb-2 block pl-4 text-sm text-foreground">
Your username
</span>
<Input
aria-label="Community username"
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
className="h-14 rounded-2xl border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.28)] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg)/0.95)] px-5 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.5)] md:text-sm"
data-testid="community-profile-name-key"
disabled={isPending || isUploadingAvatar}
id="community-display-name"
onChange={(event) => onDisplayNameChange(event.target.value)}
placeholder="Enter your username here"
ref={nameInputRef}
spellCheck={false}
type="text"
value={displayName}
/>
</label>
</div>
{errorMessage ? (
<p className="mt-4 text-sm text-destructive">{errorMessage}</p>
) : null}
</div>
<OnboardingFooter
className={cn(
"transition-[filter,opacity] duration-200 ease-out",
isAvatarEditorOpen && "pointer-events-none opacity-45 blur-[3px]",
)}
>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} w-20`}
data-testid="community-profile-next"
disabled={!displayName.trim() || isPending || isUploadingAvatar}
onClick={onNext}
type="button"
>
Next
</Button>
</OnboardingFooter>
<Dialog
onOpenChange={(open) => setIsAvatarEditorOpen(open)}
open={isAvatarEditorOpen}
>
<DialogContent
className="buzz-onboarding-neutral-theme w-[min(calc(100vw-2rem),920px)] max-w-[920px] gap-0 overflow-hidden rounded-[18px] bg-[rgb(var(--buzz-onboarding-avatar-dialog-bg))] px-8 pb-6 pt-10 text-sm text-foreground shadow-[0_28px_90px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.28),0_8px_28px_rgb(var(--buzz-onboarding-avatar-dialog-shadow)_/_0.18)] transition-[height] duration-[250ms] ease-out"
closeButtonClassName="right-6 top-6 h-10 w-10 rounded-full bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] text-[rgb(var(--buzz-onboarding-avatar-action-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-action-bg)/0.9)] hover:text-[rgb(var(--buzz-onboarding-avatar-action-fg))]"
data-system-color-scheme="light"
data-testid="community-avatar-editor-key-frame"
onCloseAutoFocus={(event) => {
event.preventDefault();
avatarTriggerRef.current?.focus();
}}
overlayVariant="transparent"
style={
avatarEditorDialogHeight === null
? undefined
: { height: avatarEditorDialogHeight }
}
>
<DialogTitle className="sr-only">Edit your avatar</DialogTitle>
<div
className="grid items-center gap-8 md:grid-cols-[240px_minmax(0,1fr)]"
ref={avatarEditorContentRef}
>
<div
className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 py-8"
data-testid="community-avatar-live-preview-panel"
>
<div className="relative h-48 w-48">
<div
className="pointer-events-none absolute inset-0 z-10"
data-testid="community-avatar-animated-preview-slot"
ref={setAnimatedPreviewEl}
/>
{isAnimatedPreviewActive ? null : localAvatarPreviewUrl ? (
<ProfileAvatar
avatarUrl={localAvatarPreviewUrl}
className="h-full w-full rounded-full text-5xl"
label={displayName.trim() || "Your profile"}
testId="community-avatar-live-preview"
/>
) : (
(() => {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
return emojiAvatar ? (
<div
aria-label={`${displayName.trim() || "Your profile"} avatar`}
className="flex h-full w-full items-center justify-center overflow-hidden rounded-full text-6xl shadow-xs"
data-testid="community-avatar-live-preview"
role="img"
style={{ backgroundColor: emojiAvatar.color }}
>
<span
className={cn(
avatarSquishKey > 0 && "buzz-avatar-squish",
)}
data-testid="community-avatar-live-preview-emoji"
key={avatarSquishKey}
>
{emojiAvatar.emoji}
</span>
</div>
) : (
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-full w-full rounded-full text-5xl"
label={displayName.trim() || "Your profile"}
testId="community-avatar-live-preview"
/>
);
})()
)}
</div>
{animatedPreviewCaption ? (
<p className="text-center text-sm text-muted-foreground">
{animatedPreviewCaption}
</p>
) : null}
</div>
<ProfileAvatarEditor
animatedPreviewContainer={animatedPreviewEl}
avatarUrl={avatarUrl}
disabled={isPending}
donePending={isUploadingAvatar}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onAnimatedPreviewActiveChange={setIsAnimatedPreviewActive}
onAnimatedPreviewCaptionChange={setAnimatedPreviewCaption}
onDone={() => setIsAvatarEditorOpen(false)}
onEmojiAvatarChange={animateEmojiAvatarChange}
onLocalPreviewChange={setLocalAvatarPreviewUrl}
onUploadingChange={onUploadingChange}
onUrlChange={updateAvatarUrl}
presentation="onboarding-modal"
previewName={displayName.trim() || "Your profile"}
processAnimatedAvatar={
previewMode ? processPreviewAnimatedAvatar : undefined
}
processImage={previewMode ? processPreviewImage : undefined}
testIdPrefix="community-avatar"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -57,6 +57,8 @@ type InviteRedeemFormProps = {
onConnect?: (relayWsUrl: string) => void;
onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void;
placeholder?: string;
/** Render the production form without discovery or relay requests. */
previewMode?: boolean;
variant?: "add-community" | "default" | "onboarding-spotlight";
};
@@ -69,6 +71,7 @@ export function InviteRedeemForm({
onConnect,
onRedeem,
placeholder,
previewMode = false,
variant = "default",
}: InviteRedeemFormProps) {
const formId = React.useId();
@@ -104,6 +107,7 @@ export function InviteRedeemForm({
const needsRelayField = isBareCode && defaultRelayUrl !== undefined;
React.useEffect(() => {
if (previewMode) return;
const relayWsUrl = normalizedRelayUrl
? normalizedRelayUrl
: parsedInvite && hasInviteRelay(parsedInvite)
@@ -135,9 +139,10 @@ export function InviteRedeemForm({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite]);
}, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite, previewMode]);
const canSubmit =
previewMode ||
(parsedInvite !== null &&
("relayWsUrl" in parsedInvite ||
(isBareCode && bareCodeRelayUrl.trim().length > 0))) ||
@@ -150,6 +155,10 @@ export function InviteRedeemForm({
const handleSubmit = React.useCallback(
async (event: React.FormEvent) => {
event.preventDefault();
if (previewMode) {
onConnect?.(normalizedRelayUrl ?? "wss://preview.invalid");
return;
}
if (normalizedRelayUrl) {
setPolicyError(null);
setIsLoadingPolicy(true);
@@ -257,6 +266,7 @@ export function InviteRedeemForm({
onRedeem,
parsedInvite,
policyTarget,
previewMode,
],
);

View File

@@ -1,12 +1,18 @@
import { Check, RotateCcw, ShieldCheck } from "lucide-react";
import { Check } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import type { JoinPolicy } from "@/shared/api/invites";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
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 {
ONBOARDING_PREVIEW_JOURNEYS,
type OnboardingPreviewPage,
type OnboardingPreviewVariant,
} from "../onboardingPreview";
import { BackupStep } from "./BackupStep";
import { JoinPolicyNotice } from "./JoinPolicyNotice";
import { LandingBees } from "./LandingBees";
@@ -21,6 +27,7 @@ import {
BackupPasswordPreview,
CommunityChoicePreview,
CommunityConnectingPreview,
CommunityCreatePreview,
CommunityEntryPreview,
CommunityHomePreview,
CommunityProfilePreview,
@@ -30,28 +37,10 @@ import {
WelcomeChannelPreview,
} from "./OnboardingPreviewJourney";
import { OnboardingPreviewStep } from "./OnboardingPreviewShell";
import { OnboardingPreviewControls } from "./OnboardingPreviewControls";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { SetupStepPreview } from "./SetupStepPreview";
type PreviewPage =
| "landing"
| "email"
| "identity-key"
| "sign-in"
| "sign-in-key"
| "forgot-password"
| "backup-options"
| "backup-password"
| "setup"
| "config"
| "community-choice"
| "community-entry"
| "community-connecting"
| "community-profile"
| "starter-team"
| "welcome-channel"
| "community-home";
const EMAIL_SIGNUP_POLICY: JoinPolicy = {
ageAttestationRequired: true,
privacyMarkdown: "available",
@@ -59,35 +48,11 @@ const EMAIL_SIGNUP_POLICY: JoinPolicy = {
version: "email-signup-workshop",
};
function PreviewBanner({ onRestart }: { onRestart: () => void }) {
return (
<aside
className="fixed right-4 top-4 z-[100] flex max-w-sm items-center gap-3 rounded-2xl border border-foreground/15 bg-background/95 px-4 py-3 text-left shadow-lg backdrop-blur"
data-testid="onboarding-preview-banner"
>
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">Workshop preview</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
Changes in this workshop aren’t saved.
</p>
</div>
<Button
aria-label="Restart onboarding preview"
className="h-8 w-8 shrink-0"
data-testid="onboarding-preview-restart"
onClick={onRestart}
size="icon"
type="button"
variant="ghost"
>
<RotateCcw className="h-4 w-4" aria-hidden />
</Button>
</aside>
);
}
function Landing({ onNavigate }: { onNavigate: (page: PreviewPage) => void }) {
function Landing({
onNavigate,
}: {
onNavigate: (page: OnboardingPreviewPage) => void;
}) {
return (
<div
className="buzz-onboarding-neutral-theme buzz-startup-shell buzz-onboarding-welcome flex max-h-dvh items-start justify-center overflow-hidden px-4 py-8 text-foreground"
@@ -431,11 +396,13 @@ function EmailSignup({
onBack,
onContinue,
onPasswordChange,
total,
}: {
password: string;
onBack: () => void;
onContinue: (email: string) => void;
onPasswordChange: (password: string) => void;
total: number;
}) {
const [email, setEmail] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
@@ -444,15 +411,13 @@ function EmailSignup({
const [openPolicyDocument, setOpenPolicyDocument] = React.useState<
"terms" | "privacy" | null
>(null);
const canContinue =
email.trim().includes("@") &&
password.length > 0 &&
confirmPassword === password &&
ageConfirmed &&
agreementConfirmed;
return (
<OnboardingPreviewStep onBack={onBack} testId="onboarding-preview-email">
<OnboardingPreviewStep
onBack={onBack}
testId="onboarding-preview-email"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
transitionKey="preview-email"
@@ -467,7 +432,7 @@ function EmailSignup({
className="mt-8 w-full space-y-5 text-left"
onSubmit={(event) => {
event.preventDefault();
if (canContinue) onContinue(email);
onContinue(email);
}}
>
<PreviewCredentialsFields
@@ -497,7 +462,6 @@ function EmailSignup({
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-email-continue"
disabled={!canContinue}
onClick={() => onContinue(email)}
type="button"
>
@@ -562,6 +526,7 @@ function EmailSignIn({
onEmailChange,
onForgotPassword,
onSignInWithKey,
total,
}: {
email: string;
onBack: () => void;
@@ -569,12 +534,17 @@ function EmailSignIn({
onEmailChange: (email: string) => void;
onForgotPassword: () => void;
onSignInWithKey: () => void;
total: number;
}) {
const [password, setPassword] = React.useState("");
const canContinue = email.trim().length > 0 && password.length > 0;
return (
<OnboardingPreviewStep onBack={onBack} testId="onboarding-preview-sign-in">
<OnboardingPreviewStep
onBack={onBack}
testId="onboarding-preview-sign-in"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
transitionKey="preview-sign-in"
@@ -646,9 +616,11 @@ function EmailSignIn({
function PasswordReset({
initialEmail,
onBack,
total,
}: {
initialEmail: string;
onBack: () => void;
total: number;
}) {
const [email, setEmail] = React.useState(initialEmail);
const [submitted, setSubmitted] = React.useState(false);
@@ -658,6 +630,7 @@ function PasswordReset({
<OnboardingPreviewStep
onBack={onBack}
testId="onboarding-preview-password-reset-sent"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
@@ -678,6 +651,7 @@ function PasswordReset({
<OnboardingPreviewStep
onBack={onBack}
testId="onboarding-preview-password-reset"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-0 w-full max-w-[500px] flex-col items-center"
@@ -731,14 +705,17 @@ function PasswordReset({
function KeySignIn({
onBack,
onContinue,
total,
}: {
onBack: () => void;
onContinue: () => void;
total: number;
}) {
return (
<OnboardingPreviewStep
onBack={onBack}
testId="onboarding-preview-sign-in-key"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-[calc(100dvh-13.25rem)] w-full max-w-[560px] flex-col items-center justify-center"
@@ -767,17 +744,19 @@ function KeySignIn({
export function OnboardingPreviewApp() {
const [run, setRun] = React.useState(0);
const [page, setPage] = React.useState<PreviewPage>("landing");
const [variant, setVariant] = React.useState<OnboardingPreviewVariant>("v3");
const [page, setPage] = React.useState<OnboardingPreviewPage>("landing");
const [signInEmail, setSignInEmail] = React.useState("");
const [signupPassword, setSignupPassword] = React.useState("");
const [passwordResetEmail, setPasswordResetEmail] = React.useState("");
const [setupBackPage, setSetupBackPage] =
React.useState<PreviewPage>("landing");
React.useState<OnboardingPreviewPage>("landing");
const [communityRoute, setCommunityRoute] =
React.useState<CommunityPreviewRoute>("join");
const [communityName, setCommunityName] = React.useState("Block Community");
const [displayName, setDisplayName] = React.useState("");
const [hasAvatar, setHasAvatar] = React.useState(false);
const [avatarUrl, setAvatarUrl] = React.useState("");
const journey = ONBOARDING_PREVIEW_JOURNEYS[variant];
React.useEffect(() => {
if (page === "landing") {
@@ -788,6 +767,7 @@ export function OnboardingPreviewApp() {
}, [page]);
const restart = React.useCallback(() => {
resetAvatarPresentations();
setPage("landing");
setPasswordResetEmail("");
setRun((current) => current + 1);
@@ -796,9 +776,25 @@ export function OnboardingPreviewApp() {
setCommunityRoute("join");
setCommunityName("Block Community");
setDisplayName("");
setHasAvatar(false);
setAvatarUrl("");
}, []);
const changeVariant = React.useCallback(
(nextVariant: OnboardingPreviewVariant) => {
setVariant(nextVariant);
restart();
},
[restart],
);
const continueFromAccount = React.useCallback(
(backPage: OnboardingPreviewPage) => {
setSetupBackPage(backPage);
setPage(journey.afterAccount);
},
[journey.afterAccount],
);
let content: React.ReactNode;
if (page === "landing") {
content = <Landing key={run} onNavigate={setPage} />;
@@ -807,11 +803,9 @@ export function OnboardingPreviewApp() {
<EmailSignup
password={signupPassword}
onBack={() => setPage("landing")}
onContinue={() => {
setSetupBackPage("email");
setPage("setup");
}}
onContinue={() => continueFromAccount("email")}
onPasswordChange={setSignupPassword}
total={journey.totalSteps}
/>
);
} else if (page === "identity-key") {
@@ -819,15 +813,16 @@ export function OnboardingPreviewApp() {
<OnboardingPreviewStep
onBack={() => setPage("landing")}
testId="onboarding-preview-identity-key"
total={journey.totalSteps}
>
<BackupStep
direction="forward"
onNext={() => {
setSetupBackPage("identity-key");
setPage("setup");
}}
onNext={() => continueFromAccount("identity-key")}
onOpenPasswordBackup={() => setPage("backup-password")}
onShowOptions={() => setPage("backup-options")}
onShowOptions={() => {
setSetupBackPage("identity-key");
setPage("backup-options");
}}
optionsExpanded={false}
previewMode
returningFromSecurity={false}
@@ -839,10 +834,11 @@ export function OnboardingPreviewApp() {
<OnboardingPreviewStep
onBack={() => setPage("identity-key")}
testId="onboarding-preview-backup-options"
total={journey.totalSteps}
>
<BackupStep
direction="forward"
onNext={() => setPage("setup")}
onNext={() => setPage(journey.afterAccount)}
onOpenPasswordBackup={() => setPage("backup-password")}
onShowOptions={() => setPage("backup-options")}
optionsExpanded
@@ -863,16 +859,14 @@ export function OnboardingPreviewApp() {
<EmailSignIn
email={signInEmail}
onBack={() => setPage("landing")}
onContinue={() => {
setSetupBackPage("sign-in");
setPage("setup");
}}
onContinue={() => continueFromAccount("sign-in")}
onEmailChange={setSignInEmail}
onForgotPassword={() => {
setPasswordResetEmail(signInEmail);
setPage("forgot-password");
}}
onSignInWithKey={() => setPage("sign-in-key")}
total={journey.totalSteps}
/>
);
} else if (page === "forgot-password") {
@@ -880,16 +874,15 @@ export function OnboardingPreviewApp() {
<PasswordReset
initialEmail={passwordResetEmail}
onBack={() => setPage("sign-in")}
total={journey.totalSteps}
/>
);
} else if (page === "sign-in-key") {
content = (
<KeySignIn
onBack={() => setPage("sign-in")}
onContinue={() => {
setSetupBackPage("sign-in-key");
setPage("setup");
}}
onContinue={() => continueFromAccount("sign-in-key")}
total={journey.totalSteps}
/>
);
} else if (page === "setup") {
@@ -909,24 +902,40 @@ export function OnboardingPreviewApp() {
} else if (page === "community-choice") {
content = (
<CommunityChoicePreview
onBack={() => setPage("config")}
current={journey.communityStep}
includeExistingCommunity={journey.includeExistingCommunity}
onBack={() => setPage(journey.communityChoiceBack ?? setupBackPage)}
onChoose={(route) => {
setCommunityRoute(route);
setPage("community-entry");
}}
total={journey.totalSteps}
/>
);
} else if (page === "community-entry") {
content = (
<CommunityEntryPreview
onBack={() => setPage("community-choice")}
onContinue={(name) => {
setCommunityName(name);
setPage("community-connecting");
}}
route={communityRoute}
/>
);
content =
communityRoute === "create" ? (
<CommunityCreatePreview
current={journey.communityStep}
onBack={() => setPage("community-choice")}
onContinue={(name) => {
setCommunityName(name);
setPage(journey.afterCommunityEntry);
}}
total={journey.totalSteps}
/>
) : (
<CommunityEntryPreview
current={journey.communityStep}
onBack={() => setPage("community-choice")}
onContinue={(name) => {
setCommunityName(name);
setPage(journey.afterCommunityEntry);
}}
route={communityRoute}
total={journey.totalSteps}
/>
);
} else if (page === "community-connecting") {
content = (
<CommunityConnectingPreview
@@ -938,12 +947,14 @@ export function OnboardingPreviewApp() {
} else if (page === "community-profile") {
content = (
<CommunityProfilePreview
avatarUrl={avatarUrl}
current={journey.profileStep}
displayName={displayName}
hasAvatar={hasAvatar}
onAvatarChange={() => setHasAvatar((current) => !current)}
onAvatarUrlChange={setAvatarUrl}
onBack={() => setPage("community-entry")}
onDisplayNameChange={setDisplayName}
onNext={() => setPage("starter-team")}
onNext={() => setPage(journey.afterProfile)}
total={journey.totalSteps}
/>
);
} else if (page === "starter-team") {
@@ -964,15 +975,20 @@ export function OnboardingPreviewApp() {
} else {
content = (
<CommunityHomePreview
avatarUrl={avatarUrl}
communityName={communityName}
onBack={() => setPage("welcome-channel")}
displayName={displayName}
/>
);
}
return (
<>
<PreviewBanner onRestart={restart} />
<OnboardingPreviewControls
onRestart={restart}
onVariantChange={changeVariant}
variant={variant}
/>
{content}
</>
);

View File

@@ -0,0 +1,60 @@
import { RotateCcw, ShieldCheck } from "lucide-react";
import type { OnboardingPreviewVariant } from "../onboardingPreview";
import { Button } from "@/shared/ui/button";
import { SegmentedControl } from "@/shared/ui/segmented-control";
const PREVIEW_VARIANT_OPTIONS = [
{ label: "Onboarding today", value: "today" },
{ label: "V3", value: "v3" },
] as const;
export function OnboardingPreviewControls({
onRestart,
onVariantChange,
variant,
}: {
onRestart: () => void;
onVariantChange: (variant: OnboardingPreviewVariant) => void;
variant: OnboardingPreviewVariant;
}) {
return (
<aside
className="fixed right-4 top-4 z-[100] flex w-[320px] max-w-[calc(100vw-2rem)] flex-col gap-3 rounded-2xl border border-foreground/15 bg-background/95 px-4 py-3 text-left shadow-lg backdrop-blur"
data-testid="onboarding-preview-banner"
>
<div className="flex items-center gap-3">
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
Workshop preview
</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
Changes in this workshop aren’t saved.
</p>
</div>
<Button
aria-label="Restart onboarding preview"
className="h-8 w-8 shrink-0"
data-testid="onboarding-preview-restart"
onClick={onRestart}
size="icon"
type="button"
variant="ghost"
>
<RotateCcw className="h-4 w-4" aria-hidden />
</Button>
</div>
<SegmentedControl
className="w-full"
legend="Onboarding version"
onValueChange={onVariantChange}
optionTestIdPrefix="onboarding-preview-variant"
options={PREVIEW_VARIANT_OPTIONS}
size="wide"
testId="onboarding-preview-variant"
value={variant}
/>
</aside>
);
}

View File

@@ -1,9 +1,13 @@
import { Check, Plus, Users } from "lucide-react";
import { Check, Copy, Users } from "lucide-react";
import * as React from "react";
import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding";
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { InviteRedeemForm } from "./InviteRedeemForm";
import { CommunityProfileStage } from "./CommunityProfileStage";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
@@ -11,11 +15,15 @@ import {
import { OnboardingFooter } from "./OnboardingFooter";
import { OnboardingPreviewStep } from "./OnboardingPreviewShell";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { WelcomeChannelAppPreview } from "./WelcomeChannelAppPreview";
const FIELD_CLASS =
"h-12 rounded-2xl border-foreground/15 bg-white px-4 text-sm shadow-none";
const COMMUNITY_OPTION_CLASS =
"w-full max-w-[320px] items-center px-6 py-4 text-center text-sm font-normal leading-6 text-foreground [--buzz-card-textured-min-height:88px] transition-[filter] duration-150 ease-out hover:brightness-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35";
const PREVIEW_PUBLIC_ID = pubkeyToNpub(
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
);
function LabeledField({
id,
@@ -313,17 +321,32 @@ export function DefaultConfigPreview({
export type CommunityPreviewRoute = "join" | "create" | "existing";
export function CommunityChoicePreview({
current = 5,
includeExistingCommunity = true,
onBack,
onChoose,
total,
}: {
current?: number;
includeExistingCommunity?: boolean;
onBack: () => void;
onChoose: (route: CommunityPreviewRoute) => void;
total?: number;
}) {
const routes: ReadonlyArray<readonly [CommunityPreviewRoute, string]> = [
["join", "Join a community"],
["create", "Create a community"],
...(includeExistingCommunity
? ([["existing", "I already have a community"]] as const)
: []),
];
return (
<OnboardingPreviewStep
current={5}
current={current}
onBack={onBack}
testId="onboarding-preview-community-choice"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-full w-full flex-col items-center"
@@ -332,18 +355,13 @@ export function CommunityChoicePreview({
<div className="w-full max-w-[760px]">
<h1 className="text-title font-normal">Join or create a community</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
Join with an invite, create your own community, or reconnect one you
already have.
{includeExistingCommunity
? "Join with an invite, create your own community, or reconnect one you already have."
: "Join with an invite or create your own community."}
</p>
</div>
<div className="flex w-full flex-1 flex-col items-center justify-center gap-8 py-10">
{(
[
["join", "Join a community"],
["create", "Create a community"],
["existing", "I already have a community"],
] as const
).map(([route, label]) => (
<div className="flex w-full flex-1 translate-y-16 flex-col items-center justify-center gap-20 py-8">
{routes.map(([route, label]) => (
<Card
asChild
className={COMMUNITY_OPTION_CLASS}
@@ -366,69 +384,119 @@ export function CommunityChoicePreview({
}
export function CommunityEntryPreview({
current = 5,
onBack,
onContinue,
route,
total,
}: {
current?: number;
onBack: () => void;
onContinue: (communityName: string) => void;
route: CommunityPreviewRoute;
route: Exclude<CommunityPreviewRoute, "create">;
total?: number;
}) {
const [value, setValue] = React.useState("");
const isCreate = route === "create";
const heading = isCreate
? "Create a community"
: route === "existing"
? "Reconnect to your community"
: "Join a community";
const [copiedPublicId, setCopiedPublicId] = React.useState(false);
const heading =
route === "existing" ? "Reconnect to your community" : "Join a community";
const continueToCommunity = () => onContinue("Block Community");
return (
<OnboardingPreviewStep
current={5}
current={current}
onBack={onBack}
testId="onboarding-preview-community-entry"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-full w-full max-w-[620px] flex-col items-center"
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
transitionKey={`preview-community-${route}`}
>
<h1 className="text-title font-normal">{heading}</h1>
<p className="mt-3 max-w-[500px] text-sm leading-6 text-foreground/80">
{isCreate
? "Choose a name for the space where your people and agents will work together."
: route === "existing"
<div className="w-full max-w-[620px]">
<h1 className="text-title font-normal">{heading}</h1>
<p className="mt-3 text-sm leading-6 text-foreground/80">
{route === "existing"
? "Enter the community URL or an invite link. Your role will be restored when you connect."
: "Enter the invite link or community URL you received."}
</p>
<form
className="flex w-full flex-1 flex-col items-center justify-center gap-6 py-10"
onSubmit={(event) => {
event.preventDefault();
onContinue(
isCreate ? value.trim() || "Your community" : "Block Community",
);
}}
>
<LabeledField
id="onboarding-preview-community-value"
label={isCreate ? "Community name" : "Invite or community URL"}
onChange={setValue}
placeholder={
isCreate
? "Enter a community name"
: "Paste an invite, code, or URL"
}
type={isCreate ? "text" : "url"}
value={value}
</p>
</div>
<div className="flex w-full flex-1 flex-col items-center justify-center gap-16">
<InviteRedeemForm
error={null}
isRedeeming={false}
onCancel={onBack}
onConnect={continueToCommunity}
onRedeem={continueToCommunity}
placeholder="Invite link or community URL"
previewMode
variant="onboarding-spotlight"
/>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-community-continue"
type="submit"
>
{isCreate ? "Create community" : "Continue"}
</Button>
</form>
{route === "join" ? (
<div className="w-full max-w-[560px] text-left">
<p className="text-sm font-medium text-foreground">
Joining a private community?
</p>
<p className="mt-2 text-sm leading-6 text-foreground/75">
Some communities need the owner to add you before you can join.
Copy your public ID and send it to the community owner.
</p>
<div className="mt-4 flex items-center gap-3 rounded-xl border border-foreground/10 bg-background/35 px-4 py-3">
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/80">
{PREVIEW_PUBLIC_ID}
</code>
<Button
aria-label="Copy public ID"
className="h-9 shrink-0 rounded-full px-3"
onClick={() => {
setCopiedPublicId(true);
window.setTimeout(() => setCopiedPublicId(false), 1500);
}}
size="sm"
type="button"
variant="outline"
>
{copiedPublicId ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span>{copiedPublicId ? "Copied" : "Copy"}</span>
</Button>
</div>
</div>
) : null}
</div>
</OnboardingSlideTransition>
</OnboardingPreviewStep>
);
}
export function CommunityCreatePreview({
current = 5,
onBack,
onContinue,
total,
}: {
current?: number;
onBack: () => void;
onContinue: (communityName: string) => void;
total?: number;
}) {
return (
<OnboardingPreviewStep
current={current}
testId="onboarding-preview-community-entry"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-full w-full flex-col items-center"
transitionKey="preview-community-create"
>
<HostedCommunityOnboarding
onBack={onBack}
onPreviewContinue={onContinue}
previewMode
/>
</OnboardingSlideTransition>
</OnboardingPreviewStep>
);
@@ -471,80 +539,48 @@ export function CommunityConnectingPreview({
}
export function CommunityProfilePreview({
avatarUrl,
current = 6,
displayName,
hasAvatar,
onAvatarChange,
onAvatarUrlChange,
onBack,
onDisplayNameChange,
onNext,
total,
}: {
avatarUrl: string;
current?: number;
displayName: string;
hasAvatar: boolean;
onAvatarChange: () => void;
onAvatarUrlChange: (value: string) => void;
onBack: () => void;
onDisplayNameChange: (value: string) => void;
onNext: () => void;
total?: number;
}) {
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
return (
<OnboardingPreviewStep
current={6}
current={current}
onBack={onBack}
testId="onboarding-preview-community-profile"
total={total}
>
<OnboardingSlideTransition
className="flex min-h-full w-full max-w-[500px] flex-col items-center"
transitionKey="preview-community-profile"
>
<div className="shrink-0">
<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>
<div className="flex w-full flex-1 flex-col items-center justify-center pt-8">
<button
aria-label={hasAvatar ? "Change your avatar" : "Add an avatar"}
className="flex h-36 w-36 items-center justify-center overflow-hidden rounded-full bg-white/30 text-[var(--buzz-onboarding-backup-ink)] shadow-xs transition-colors hover:bg-white/40"
data-testid="onboarding-preview-profile-avatar"
onClick={onAvatarChange}
type="button"
>
{hasAvatar ? (
<span className="text-5xl" role="img" aria-label="Bee avatar">
🐝
</span>
) : (
<Plus className="h-7 w-7" aria-hidden />
)}
</button>
<label
className="mt-7 block w-full max-w-[412px] text-left"
htmlFor="onboarding-preview-community-name"
>
<span className="mb-2 block pl-4 text-sm text-foreground">
Your username
</span>
<Input
autoComplete="username"
className="h-14 rounded-2xl border-foreground/25 bg-white/95 px-5 text-sm shadow-none"
id="onboarding-preview-community-name"
onChange={(event) => onDisplayNameChange(event.target.value)}
placeholder="Enter your username here"
value={displayName}
/>
</label>
</div>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-preview-profile-next"
disabled={!displayName.trim()}
onClick={onNext}
>
Next
</Button>
</OnboardingFooter>
<CommunityProfileStage
avatarUrl={avatarUrl}
displayName={displayName}
isPending={false}
isUploadingAvatar={isUploadingAvatar}
onAvatarUrlChange={onAvatarUrlChange}
onDisplayNameChange={onDisplayNameChange}
onNext={onNext}
onUploadingChange={setIsUploadingAvatar}
previewMode
/>
</OnboardingSlideTransition>
</OnboardingPreviewStep>
);
@@ -666,40 +702,19 @@ export function WelcomeChannelPreview({
}
export function CommunityHomePreview({
avatarUrl,
communityName,
onBack,
displayName,
}: {
avatarUrl: string;
communityName: string;
onBack: () => void;
displayName: string;
}) {
return (
<OnboardingPreviewStep
onBack={onBack}
current={7}
testId="onboarding-preview-community-home"
>
<OnboardingSlideTransition
className="flex min-h-full w-full max-w-[760px] flex-col items-center justify-center"
transitionKey="preview-community-home"
>
<p className="text-sm font-medium uppercase tracking-[0.16em] text-foreground/60">
{communityName}
</p>
<h1 className="mt-3 text-title font-normal">Welcome to Buzz</h1>
<p className="mt-4 max-w-[480px] text-sm leading-6 text-foreground/80">
You’re in the Welcome channel. Start with a message, meet the people
and agents here, or explore the rest of the community.
</p>
<Card
className="mt-10 w-full max-w-[620px] px-6 py-6 text-left"
variant="textured"
>
<p className="text-sm font-medium">Fizz</p>
<p className="mt-2 text-sm leading-6 text-foreground/75">
Welcome! I can show you around and help you get started.
</p>
</Card>
</OnboardingSlideTransition>
</OnboardingPreviewStep>
<WelcomeChannelAppPreview
avatarUrl={avatarUrl}
communityName={communityName}
displayName={displayName}
/>
);
}

View File

@@ -10,12 +10,14 @@ export function OnboardingPreviewStep({
onBack,
security = false,
testId,
total,
}: {
children: React.ReactNode;
current?: number;
onBack?: () => void;
security?: boolean;
testId: string;
total?: number;
}) {
return (
<div
@@ -25,7 +27,7 @@ export function OnboardingPreviewStep({
data-testid={testId}
>
<StartupWindowDragRegion />
{!security ? <OnboardingChrome current={current} /> : null}
{!security ? <OnboardingChrome current={current} total={total} /> : null}
<OnboardingFooterProvider
backAction={onBack ? { onClick: onBack } : undefined}
>

View File

@@ -0,0 +1,321 @@
import {
AtSign,
Bot,
ChevronLeft,
ChevronRight,
FolderKanban,
Hash,
Home,
Mic,
Paperclip,
Plus,
Search,
Send,
Smile,
Sparkles,
} from "lucide-react";
import * as React from "react";
import {
WelcomeComposerGuidanceLayer,
type WelcomeComposerBannerState,
} from "@/features/channels/ui/WelcomeComposerBanner";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ChannelIntroBlock } from "@/features/messages/ui/ChannelIntroBlock";
import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { cn } from "@/shared/lib/cn";
const CHANNEL_ROW_CLASS =
"flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition-colors";
export function WelcomeChannelAppPreview({
avatarUrl,
communityName,
displayName,
}: {
avatarUrl: string;
communityName: string;
displayName: string;
}) {
const [bannerState, setBannerState] =
React.useState<WelcomeComposerBannerState>("prompt");
const [draft, setDraft] = React.useState("");
const [messages, setMessages] = React.useState<string[]>([]);
const [activePreviewAction, setActivePreviewAction] = React.useState<
string | null
>(null);
const profileName = displayName.trim() || "Your profile";
const submitDraft = (event: React.FormEvent) => {
event.preventDefault();
const message = draft.trim();
if (!message) return;
setMessages((current) => [...current, message]);
setDraft("");
};
const intro = {
actions: [
{
icon: <Hash aria-hidden className="h-4 w-4" />,
label: "Browse channels",
onClick: () => setActivePreviewAction("Browse channels"),
testId: "welcome-intro-action-browse-channels",
},
{
icon: <Plus aria-hidden className="h-4 w-4" />,
label: "Create a channel",
onClick: () => setActivePreviewAction("Create a channel"),
testId: "welcome-intro-action-create-channel",
},
{
icon: <Bot aria-hidden className="h-4 w-4" />,
label: "Create an agent",
onClick: () => setActivePreviewAction("Create an agent"),
testId: "welcome-intro-action-create-agent",
},
],
channelKindLabel: "private welcome channel",
channelName: "Welcome",
description: null,
icon: <Sparkles aria-hidden className="h-7 w-7" />,
};
return (
<div
className="flex h-dvh w-full flex-col overflow-hidden bg-background text-foreground"
data-testid="onboarding-preview-community-home"
>
<StartupWindowDragRegion />
<div
className="flex h-12 shrink-0 items-center border-b border-sidebar-border bg-sidebar pl-20 pr-4 text-sidebar-foreground"
data-tauri-drag-region
>
<div className="flex items-center gap-0.5">
<Button
aria-label="Go back"
className="h-7 w-6 rounded text-sidebar-foreground/45"
disabled
size="icon"
variant="ghost"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
aria-label="Go forward"
className="h-7 w-6 rounded text-sidebar-foreground/45"
disabled
size="icon"
variant="ghost"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<button
className="mx-auto flex h-7 w-full max-w-[420px] items-center justify-center gap-2 rounded-md bg-sidebar-accent/65 px-3 text-xs text-sidebar-foreground/65"
onClick={() => setActivePreviewAction("Search")}
type="button"
>
<Search className="h-3.5 w-3.5" aria-hidden />
Search {communityName}
</button>
<div className="w-[72px]" aria-hidden />
</div>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[300px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar p-2 text-sidebar-foreground">
<button
className="flex h-10 items-center gap-2 rounded-lg px-2 text-left hover:bg-sidebar-accent"
type="button"
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
B
</span>
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{communityName}
</span>
</button>
<nav className="mt-2 space-y-0.5" aria-label="Workspace">
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
type="button"
>
<Home
className="h-4 w-4 text-sidebar-foreground/65"
aria-hidden
/>
Home
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
type="button"
>
<FolderKanban
className="h-4 w-4 text-sidebar-foreground/65"
aria-hidden
/>
Projects
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
type="button"
>
<Bot className="h-4 w-4 text-sidebar-foreground/65" aria-hidden />
Agents
</button>
</nav>
<div className="mt-5 flex items-center justify-between px-2 text-xs font-medium text-sidebar-foreground/55">
<span>Channels</span>
<Plus className="h-3.5 w-3.5" aria-hidden />
</div>
<div className="mt-1 space-y-0.5">
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
data-testid="channel-general"
type="button"
>
<Hash
className="h-4 w-4 text-sidebar-foreground/55"
aria-hidden
/>
general
</button>
<button
className={cn(CHANNEL_ROW_CLASS, "hover:bg-sidebar-accent")}
data-testid="channel-welcome-everyone"
type="button"
>
<Hash
className="h-4 w-4 text-sidebar-foreground/55"
aria-hidden
/>
welcome-everyone
</button>
<button
className={cn(
CHANNEL_ROW_CLASS,
"bg-sidebar-accent font-medium text-sidebar-accent-foreground",
)}
data-testid="channel-Welcome"
type="button"
>
<Sparkles className="h-4 w-4" aria-hidden />
Welcome
</button>
</div>
<div className="mt-auto flex items-center gap-2 rounded-lg px-2 py-2 hover:bg-sidebar-accent">
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-8 w-8 rounded-lg text-xs"
label={profileName}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{profileName}</p>
<p className="truncate text-xs text-sidebar-foreground/55">
Available
</p>
</div>
</div>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
<header className="relative z-20 flex h-14 shrink-0 items-center border-b border-border/55 bg-background/80 px-5 backdrop-blur-md">
<Hash className="h-4 w-4 text-muted-foreground" aria-hidden />
<h1
className="ml-1 text-base font-semibold"
data-testid="chat-title"
>
Welcome
</h1>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-56 pt-4">
<div className="mx-auto w-full max-w-[920px]">
<ChannelIntroBlock intro={intro} />
{messages.map((message, index) => (
<div
className="mt-5 flex items-start gap-3 px-3 text-left"
key={`${message}-${index.toString()}`}
>
<ProfileAvatar
avatarUrl={avatarUrl || null}
className="h-9 w-9 rounded-lg text-xs"
label={profileName}
/>
<div>
<p className="text-sm font-semibold">{profileName}</p>
<p className="mt-1 text-sm leading-5">{message}</p>
</div>
</div>
))}
</div>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 isolate before:absolute before:inset-x-0 before:bottom-0 before:-z-10 before:h-24 before:bg-gradient-to-b before:from-transparent before:to-background before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:-z-10 after:h-12 after:bg-background after:content-['']">
<div className="composer-dock composer-overlay-corner-masks relative pointer-events-auto">
{bannerState !== "hidden" ? (
<WelcomeComposerGuidanceLayer
onDismiss={() => setBannerState("hidden")}
state={bannerState}
>
{null}
</WelcomeComposerGuidanceLayer>
) : null}
<ComposerDockBackdrop gutterClassName="inset-x-5" />
<form
className="relative z-10 mx-5 rounded-2xl border border-border/50 bg-background/80 px-4 pb-2 pt-3 shadow-none backdrop-blur-md"
data-testid="message-composer"
onSubmit={submitDraft}
>
<textarea
aria-label="Message Welcome"
className="min-h-11 w-full resize-none bg-transparent text-sm leading-5 outline-none placeholder:text-muted-foreground/65"
data-testid="welcome-preview-composer-input"
onChange={(event) => setDraft(event.target.value)}
placeholder="Message #Welcome"
rows={2}
value={draft}
/>
<div className="flex items-center gap-1">
{[Paperclip, Smile, AtSign, Mic].map((Icon) => (
<Button
className="h-7 w-7 text-muted-foreground"
key={Icon.displayName ?? Icon.name}
size="icon"
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" aria-hidden />
</Button>
))}
<Button
aria-label="Send message"
className="ml-auto h-7 w-7 rounded-lg"
disabled={!draft.trim()}
size="icon"
type="submit"
>
<Send className="h-4 w-4" aria-hidden />
</Button>
</div>
</form>
</div>
</div>
{activePreviewAction ? (
<div
className="absolute right-5 top-16 z-50 rounded-lg border border-border bg-background px-3 py-2 text-xs shadow-lg"
role="status"
>
{activePreviewAction}
</div>
) : null}
</main>
</div>
</div>
);
}

View File

@@ -228,12 +228,13 @@ export function preloadAvatarSegmenter(): void {
export async function recordAnimatedAvatarFrames(
video: HTMLVideoElement,
options: {
removeBackground?: boolean;
signal?: AbortSignal;
onProgress?: (fraction: number) => void;
} = {},
): Promise<AnimatedAvatarRecording> {
const { onProgress, signal } = options;
const handle = await loadSegmenter();
const { onProgress, removeBackground = true, signal } = options;
const handle = removeBackground ? await loadSegmenter() : null;
const canvas = document.createElement("canvas");
canvas.width = ANIMATED_AVATAR_CAPTURE_SIZE;

View File

@@ -4,10 +4,15 @@ import {
type AvatarCameraDevice,
type AvatarComposition,
composeAvatarFrame,
composeAvatarFrames,
DEFAULT_PERSON_SCALE,
encodeAvatarAnimation,
renderAvatarPosterPng,
} from "@/features/profile/lib/animatedAvatarCapture";
import { beginAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import type { AnimatedAvatarRecordingProcessor } from "@/features/profile/ui/AnimatedAvatarCapture.types";
import { AVATAR_COLORS } from "@/features/profile/ui/ProfileAvatarEditor.utils";
import { uploadMediaBytes } from "@/shared/api/tauri";
import { buildAnimatedAvatarUrl } from "@/shared/lib/animatedAvatar";
export type CapturePhase =
@@ -49,6 +54,40 @@ export function presentAnimatedAvatar(
return avatarUrl;
}
export async function saveAnimatedAvatar({
bitmaps,
composition,
posterIndex,
processRecording,
}: {
bitmaps: ImageBitmap[];
composition: AvatarComposition;
posterIndex: number;
processRecording?: AnimatedAvatarRecordingProcessor;
}): Promise<string> {
const composed = composeAvatarFrames(bitmaps, composition);
const posterFrame = composed[posterIndex] ?? composed[0];
if (!posterFrame) throw new Error("No frames were recorded.");
const animationBytes = encodeAvatarAnimation(composed);
const posterBytes = await renderAvatarPosterPng(posterFrame);
if (processRecording) {
return processRecording({ animationBytes, posterBytes });
}
const [animationUpload, posterUpload] = await Promise.all([
uploadMediaBytes([...animationBytes], "animated-avatar.png"),
uploadMediaBytes([...posterBytes], "animated-avatar-poster.png"),
]);
if (
!animationUpload.type.startsWith("image/") ||
!posterUpload.type.startsWith("image/")
) {
throw new Error("The relay rejected the recording. Try again.");
}
return presentAnimatedAvatar(posterUpload, animationUpload, posterBytes);
}
/** Keep things draggable but never fully lost off-frame. */
const MAX_OFFSET = 192;

View File

@@ -10,7 +10,6 @@ import {
type AvatarComposition,
buildPingPongAvatarFrames,
composeAvatarFrame,
composeAvatarFrames,
createAvatarFrameBitmaps,
DEFAULT_PERSON_OFFSET_X,
DEFAULT_PERSON_OFFSET_Y,
@@ -19,7 +18,6 @@ import {
DEFAULT_SHAPE_OFFSET_X,
DEFAULT_SHAPE_OFFSET_Y,
DEFAULT_SHAPE_SCALE,
encodeAvatarAnimation,
MAX_PERSON_SCALE,
MAX_SHAPE_SCALE,
MIN_PERSON_SCALE,
@@ -28,7 +26,6 @@ import {
openAvatarCamera,
preloadAvatarSegmenter,
recordAnimatedAvatarFrames,
renderAvatarPosterPng,
stopAvatarCamera,
} from "@/features/profile/lib/animatedAvatarCapture";
import { AnimatedAvatarBackdropPanel } from "@/features/profile/ui/AnimatedAvatarBackdropPanel";
@@ -49,8 +46,8 @@ import {
defaultPersonScaleForSource,
PERSON_SIZE_TIP,
preferredCameraDevice,
presentAnimatedAvatar,
randomBackdropColor,
saveAnimatedAvatar,
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
import {
AnimatedAvatarReviewNav,
@@ -63,7 +60,6 @@ import {
hsvToHex,
normalizeHue,
} from "@/features/profile/ui/ProfileAvatarEditor.utils";
import { uploadMediaBytes } from "@/shared/api/tauri";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
@@ -81,6 +77,7 @@ export function AnimatedAvatarCapture({
showApplyButton = true,
autoStartCamera = false,
compactReview = false,
processRecording,
}: AnimatedAvatarCaptureProps) {
const [phase, setPhase] = React.useState<CapturePhase>("idle");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
@@ -329,7 +326,7 @@ export function AnimatedAvatarCapture({
setPhase("starting");
releaseCamera();
// Warm the segmentation model while the user lines up their shot.
preloadAvatarSegmenter();
if (!processRecording) preloadAvatarSegmenter();
try {
let stream = await openAvatarCamera(cameraId || null);
let resolvedCameraId = cameraId ?? null;
@@ -386,6 +383,7 @@ export function AnimatedAvatarCapture({
[
refreshCameraDevices,
releaseCamera,
processRecording,
selectedCameraId,
selectedCameraSource,
],
@@ -444,6 +442,7 @@ export function AnimatedAvatarCapture({
try {
const captured = await recordAnimatedAvatarFrames(video, {
removeBackground: !processRecording,
onProgress: setRecordProgress,
signal: abort.signal,
});
@@ -474,7 +473,13 @@ export function AnimatedAvatarCapture({
} finally {
recordAbortRef.current = null;
}
}, [phase, releaseBitmaps, releaseCamera, selectedCameraSource]);
}, [
phase,
processRecording,
releaseBitmaps,
releaseCamera,
selectedCameraSource,
]);
const retake = React.useCallback(() => {
setRecording(null);
@@ -501,26 +506,13 @@ export function AnimatedAvatarCapture({
setIsSaving(true);
setErrorMessage(null);
try {
const composed = composeAvatarFrames(bitmaps, composition);
const posterFrame = composed[posterIndex] ?? composed[0];
if (!posterFrame) {
throw new Error("No frames were recorded.");
}
const animationBytes = encodeAvatarAnimation(composed);
const posterBytes = await renderAvatarPosterPng(posterFrame);
const [animationUpload, posterUpload] = await Promise.all([
uploadMediaBytes([...animationBytes], "animated-avatar.png"),
uploadMediaBytes([...posterBytes], "animated-avatar-poster.png"),
]);
if (
!animationUpload.type.startsWith("image/") ||
!posterUpload.type.startsWith("image/")
) {
setErrorMessage("The relay rejected the recording. Try again.");
return false;
}
onApply(
presentAnimatedAvatar(posterUpload, animationUpload, posterBytes),
await saveAnimatedAvatar({
bitmaps,
composition,
posterIndex,
processRecording,
}),
);
return true;
} catch (error) {
@@ -533,7 +525,7 @@ export function AnimatedAvatarCapture({
} finally {
setIsSaving(false);
}
}, [bitmaps, composition, isSaving, onApply, posterIndex]);
}, [bitmaps, composition, isSaving, onApply, posterIndex, processRecording]);
// Hand the host's Done button the current apply function whenever a
// recording is ready to upload.

View File

@@ -1,3 +1,8 @@
export type AnimatedAvatarRecordingProcessor = (input: {
animationBytes: Uint8Array;
posterBytes: Uint8Array;
}) => Promise<string>;
export type AnimatedAvatarCaptureProps = {
disabled?: boolean;
testIdPrefix: string;
@@ -11,4 +16,5 @@ export type AnimatedAvatarCaptureProps = {
showApplyButton?: boolean;
autoStartCamera?: boolean;
compactReview?: boolean;
processRecording?: AnimatedAvatarRecordingProcessor;
};

View File

@@ -77,6 +77,8 @@ export function ProfileAvatarEditor({
onAnimatedAvatarApply,
onDone,
onUploadingChange,
processAnimatedAvatar,
processImage,
showEmojiColorControlsWhenEmpty = false,
disabled,
testIdPrefix = "profile-avatar",
@@ -189,7 +191,7 @@ export function ProfileAvatarEditor({
isUploading,
openPicker,
uploadFile,
} = useAvatarUpload(uploadPreviewLifecycle);
} = useAvatarUpload({ ...uploadPreviewLifecycle, processImage });
const isInputDisabled = disabled || isUploading || isAnimatedApplyPending;
const handleAnimatedApply = React.useCallback(
(animatedUrl: string) => {
@@ -740,6 +742,7 @@ export function ProfileAvatarEditor({
onPreviewActiveChange={onAnimatedPreviewActiveChange}
onPreviewCaptionChange={onAnimatedPreviewCaptionChange}
previewContainer={animatedPreviewContainer}
processRecording={processAnimatedAvatar}
registerApply={registerAnimatedApply}
compactReview={isOnboardingModal}
showApplyButton={!onDone}

View File

@@ -1,4 +1,5 @@
import type * as React from "react";
import type { AnimatedAvatarRecordingProcessor } from "./AnimatedAvatarCapture.types";
export type AvatarMode = "image" | "emoji" | "animated";
export type AvatarEditorPresentation = "default" | "onboarding-modal";
@@ -30,4 +31,6 @@ export type ProfileAvatarEditorProps = {
onAnimatedPreviewActiveChange?: (active: boolean) => void;
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
presentation?: AvatarEditorPresentation;
processImage?: (file: File) => Promise<string>;
processAnimatedAvatar?: AnimatedAvatarRecordingProcessor;
};

View File

@@ -15,7 +15,10 @@ import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
import { onboardingPreviewRequested } from "@/features/onboarding/onboardingPreview";
import { OnboardingPreviewApp } from "@/features/onboarding/ui/OnboardingPreviewApp";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import {
NonPersistentThemeProvider,
ThemeProvider,
} from "@/shared/theme/ThemeProvider";
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
import { Toaster } from "@/shared/ui/sonner";
@@ -114,7 +117,9 @@ function renderOnboardingPreview() {
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RootErrorBoundary>
<OnboardingPreviewApp />
<NonPersistentThemeProvider>
<OnboardingPreviewApp />
</NonPersistentThemeProvider>
</RootErrorBoundary>
</React.StrictMode>,
);

View File

@@ -89,6 +89,41 @@ type ThemeProviderProps = {
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const NON_PERSISTENT_THEME_VALUE: ThemeContextValue = {
accentColor: DEFAULT_ACCENT,
applyAppearance: () => undefined,
followSystem: false,
glassBackground: false,
glassBackgroundSupported: false,
glassOpacity: 65,
hasPair: false,
isDark: false,
isLoading: false,
prominentActiveTab: false,
selectedThemeName: "buzz",
setAccentColor: () => undefined,
setFollowSystem: () => undefined,
setGlassBackground: () => undefined,
setGlassOpacity: () => undefined,
setProminentActiveTab: () => undefined,
setTheme: () => undefined,
terminalPalette: null,
themeName: "buzz",
};
/** Supplies theme context without reading, writing, or applying preferences. */
export function NonPersistentThemeProvider({
children,
}: {
children: ReactNode;
}) {
return (
<ThemeContext.Provider value={NON_PERSISTENT_THEME_VALUE}>
{children}
</ThemeContext.Provider>
);
}
function isValidThemeName(name: string): name is SyntaxThemeName {
return (SYNTAX_THEMES as readonly string[]).includes(name);
}