Polish onboarding avatar capture (#2118)

Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
cynfria
2026-07-19 11:14:06 -07:00
committed by GitHub
co-authored by npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 Wes Pinky
parent e25ed00248
commit 46a63a864b
14 changed files with 1074 additions and 470 deletions
@@ -1,6 +1,6 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Plus, Users, X } from "lucide-react";
import { Plus, Users } from "lucide-react";
import {
markCommunityOnboardingComplete,
@@ -25,7 +25,9 @@ import { listPersonas } from "@/shared/api/tauriPersonas";
import { relayClient } from "@/shared/api/relayClient";
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";
@@ -34,7 +36,6 @@ import {
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter";
import { ONBOARDING_KEY_FRAME_CLASS } from "./NsecMaskedDisplay";
function isRelayMembershipDeniedError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
@@ -71,10 +72,12 @@ function AvatarCircle({
avatarUrl,
onClick,
previewName,
triggerRef,
}: {
avatarUrl: string;
onClick: () => void;
previewName: string;
triggerRef?: React.Ref<HTMLButtonElement>;
}) {
const emojiAvatar = parseEmojiAvatarDataUrl(avatarUrl);
const hasAvatar = avatarUrl.trim().length > 0;
@@ -85,6 +88,7 @@ function AvatarCircle({
className="group block shrink-0 rounded-full"
data-testid="community-avatar-open"
onClick={onClick}
ref={triggerRef}
type="button"
>
{emojiAvatar ? (
@@ -138,6 +142,7 @@ export function CommunityOnboardingFlow({
}) {
const { transaction, update, clear } = useCommunityOnboarding();
const queryClient = useQueryClient();
const systemColorScheme = useSystemColorScheme();
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
@@ -154,6 +159,10 @@ export function CommunityOnboardingFlow({
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);
// 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.
@@ -276,6 +285,25 @@ export function CommunityOnboardingFlow({
}
}, [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) {
@@ -353,6 +381,7 @@ export function CommunityOnboardingFlow({
isCurtainFading &&
"pointer-events-none opacity-0 transition-opacity ease-out motion-reduce:transition-none",
)}
data-system-color-scheme={systemColorScheme}
data-testid="community-onboarding-flow"
style={
isCurtainFading
@@ -405,93 +434,120 @@ export function CommunityOnboardingFlow({
</div>
</>
) : isProfileStage ? (
isAvatarEditorOpen ? (
<>
<div
className={cn("relative", ONBOARDING_KEY_FRAME_CLASS)}
data-testid="community-avatar-editor-key-frame"
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"
>
<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 className="w-full" 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. Theyll show up on your messages,
reactions, and agent handoffs.
</p>
<div className="mt-8 flex w-full flex-col items-center">
<AvatarCircle
avatarUrl={avatarUrl}
onClick={() => setIsAvatarEditorOpen(true)}
previewName={displayName.trim() || "Your profile"}
/>
<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 name
</span>
<Input
aria-label="Community display name"
autoCapitalize="words"
autoComplete="name"
autoCorrect="off"
className="h-14 rounded-2xl border-[color:rgb(113_113_6_/_0.28)] bg-white/95 px-5 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-1 focus-visible:ring-[var(--buzz-onboarding-backup-ink)] md:text-sm"
data-testid="community-profile-name-key"
disabled={isPending || isUploadingAvatar}
id="community-display-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder="First and last name"
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>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="community-profile-next"
disabled={
!displayName.trim() || isPending || isUploadingAvatar
}
onClick={() => void saveProfile()}
type="button"
<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"
>
Next
</Button>
</OnboardingFooter>
</>
)
<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}
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),560px)] max-w-[560px] 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 ref={avatarEditorContentRef}>
<ProfileAvatarEditor
avatarUrl={avatarUrl}
disabled={isPending}
donePending={isUploadingAvatar}
emojiPickerTheme="auto"
emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS}
onDone={() => setIsAvatarEditorOpen(false)}
onUploadingChange={setIsUploadingAvatar}
onUrlChange={setAvatarUrl}
presentation="onboarding-modal"
previewName={displayName.trim() || "Your profile"}
testIdPrefix="community-avatar"
/>
</div>
</DialogContent>
</Dialog>
</>
) : (
<>
<h1 className="text-title font-normal">Meet your starter team</h1>
@@ -1,3 +1,5 @@
import { cn } from "@/shared/lib/cn";
import {
AVATAR_COLOR_SWATCHES,
CUSTOM_AVATAR_COLOR_SWATCH,
@@ -6,6 +8,7 @@ import {
type AnimatedAvatarBackdropPanelProps = {
backdropColor: string | null;
compact?: boolean;
disabled?: boolean;
isCustomBackdropSelected: boolean;
isSaving: boolean;
@@ -16,6 +19,7 @@ type AnimatedAvatarBackdropPanelProps = {
export function AnimatedAvatarBackdropPanel({
backdropColor,
compact = false,
disabled = false,
isCustomBackdropSelected,
isSaving,
@@ -25,66 +29,70 @@ export function AnimatedAvatarBackdropPanel({
}: AnimatedAvatarBackdropPanelProps) {
return (
<div
className="grid gap-4 rounded-xl bg-muted p-4 transition-colors duration-[250ms] ease-out"
data-testid={`${testIdPrefix}-animated-color-panel`}
className={cn(
"grid grid-cols-8 justify-items-center rounded-xl bg-muted transition-colors duration-[250ms] ease-out",
compact ? "gap-2 p-3" : "gap-3 p-4",
)}
data-testid={`${testIdPrefix}-animated-backdrop-grid`}
>
<div
className="grid grid-cols-8 justify-items-center gap-3"
data-testid={`${testIdPrefix}-animated-backdrop-grid`}
>
{AVATAR_COLOR_SWATCHES.map((swatch) => {
const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH;
const isSelected = isCustomSwatch
? isCustomBackdropSelected
: backdropColor !== null &&
swatch.toUpperCase() === backdropColor.toUpperCase();
{AVATAR_COLOR_SWATCHES.map((swatch) => {
const isCustomSwatch = swatch === CUSTOM_AVATAR_COLOR_SWATCH;
const isSelected = isCustomSwatch
? isCustomBackdropSelected
: backdropColor !== null &&
swatch.toUpperCase() === backdropColor.toUpperCase();
return (
<button
aria-label={
isCustomSwatch
? "Choose custom backdrop color"
: `Use ${swatch} backdrop`
return (
<button
aria-label={
isCustomSwatch
? "Choose custom backdrop color"
: `Use ${swatch} backdrop`
}
aria-pressed={isSelected}
className={cn(
"relative rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
compact ? "h-7 w-7" : "h-10 w-10",
)}
data-testid={
isCustomSwatch
? `${testIdPrefix}-animated-backdrop-custom`
: undefined
}
disabled={disabled || isSaving}
key={swatch}
onClick={() => {
if (isCustomSwatch) {
onOpenCustomPicker();
return;
}
aria-pressed={isSelected}
className="relative h-10 w-10 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={
isCustomSwatch
? `${testIdPrefix}-animated-backdrop-custom`
: undefined
}
disabled={disabled || isSaving}
key={swatch}
onClick={() => {
if (isCustomSwatch) {
onOpenCustomPicker();
return;
}
onSelectColor(swatch);
}}
style={{
background: isCustomSwatch
? isSelected && backdropColor
? backdropColor
: "conic-gradient(from 0deg, #ff4d4d, #ffe75c, #73ef75, #63c6f2, #b141ff, #ff4d4d)"
: swatch,
}}
type="button"
>
{isSelected ? (
<span
className="absolute inset-1 rounded-full border-[3px]"
style={{
borderColor: contrastColorForBackground(
isCustomSwatch && backdropColor ? backdropColor : swatch,
),
}}
/>
) : null}
</button>
);
})}
</div>
onSelectColor(swatch);
}}
style={{
background: isCustomSwatch
? isSelected && backdropColor
? backdropColor
: "conic-gradient(from 0deg, #ff4d4d, #ffe75c, #73ef75, #63c6f2, #b141ff, #ff4d4d)"
: swatch,
}}
type="button"
>
{isSelected ? (
<span
className={cn(
"absolute rounded-full border-[3px]",
compact ? "inset-0.5" : "inset-1",
)}
style={{
borderColor: contrastColorForBackground(
isCustomSwatch && backdropColor ? backdropColor : swatch,
),
}}
/>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,102 @@
import { Video } from "lucide-react";
import { motion } from "motion/react";
import { AnimatedAvatarCameraPicker } from "@/features/profile/ui/AnimatedAvatarCameraPicker";
import {
type CameraSource,
ENTRANCE_TRANSITION,
RECORD_SECONDS,
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type AnimatedAvatarCameraControlsProps = {
activeCameraSource: CameraSource | null;
compact: boolean;
computerDisabled: boolean;
disabled: boolean;
helpText: string | null;
iphoneDisabled: boolean;
isLive: boolean;
isStarting: boolean;
onRecord: () => void;
onRetry?: () => void;
onSelectSource: (source: CameraSource) => void;
showCameraPicker: boolean;
testIdPrefix: string;
};
export function AnimatedAvatarCameraControls({
activeCameraSource,
compact,
computerDisabled,
disabled,
helpText,
iphoneDisabled,
isLive,
isStarting,
onRecord,
onRetry,
onSelectSource,
showCameraPicker,
testIdPrefix,
}: AnimatedAvatarCameraControlsProps) {
return (
<div className="grid gap-4">
{showCameraPicker ? (
<AnimatedAvatarCameraPicker
activeCameraSource={activeCameraSource}
computerDisabled={computerDisabled}
disabled={disabled || isStarting}
iphoneDisabled={iphoneDisabled}
onSelectSource={onSelectSource}
testIdPrefix={testIdPrefix}
/>
) : null}
{helpText ? (
<p className="px-1 text-center text-sm text-muted-foreground">
{helpText}
</p>
) : null}
<div className="h-14 pt-2">
{onRetry ? (
<Button
className={cn(
"h-12 w-full rounded-xl",
compact &&
"bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))] text-[rgb(var(--buzz-onboarding-avatar-accent-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))]",
)}
data-testid={`${testIdPrefix}-animated-retry`}
disabled={disabled}
onClick={onRetry}
type="button"
>
Try camera again
</Button>
) : isLive ? (
<Button
asChild
className={cn(
"h-12 w-full rounded-xl",
compact &&
"bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))] text-[rgb(var(--buzz-onboarding-avatar-accent-fg))] hover:bg-[rgb(var(--buzz-onboarding-avatar-accent-bg))]",
)}
data-testid={`${testIdPrefix}-animated-record`}
disabled={disabled}
onClick={onRecord}
type="button"
>
<motion.button
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={ENTRANCE_TRANSITION}
>
<Video aria-hidden="true" className="mr-2 h-4 w-4" />
Capture {RECORD_SECONDS} sec video
</motion.button>
</Button>
) : null}
</div>
</div>
);
}
@@ -1,5 +1,4 @@
import { Camera, Video } from "lucide-react";
import { motion } from "motion/react";
import { Camera } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
@@ -33,7 +32,8 @@ import {
stopAvatarCamera,
} from "@/features/profile/lib/animatedAvatarCapture";
import { AnimatedAvatarBackdropPanel } from "@/features/profile/ui/AnimatedAvatarBackdropPanel";
import { AnimatedAvatarCameraPicker } from "@/features/profile/ui/AnimatedAvatarCameraPicker";
import type { AnimatedAvatarCaptureProps } from "@/features/profile/ui/AnimatedAvatarCapture.types";
import { AnimatedAvatarCameraControls } from "@/features/profile/ui/AnimatedAvatarCameraControls";
import {
AvatarFilmstripPicker,
AvatarFramingSlider,
@@ -47,11 +47,9 @@ import {
clampFrameIndex,
clampOffset,
defaultPersonScaleForSource,
ENTRANCE_TRANSITION,
PERSON_SIZE_TIP,
preferredCameraDevice,
randomBackdropColor,
RECORD_SECONDS,
} from "@/features/profile/ui/AnimatedAvatarCapture.helpers";
import {
AnimatedAvatarReviewNav,
@@ -70,32 +68,6 @@ import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
type AnimatedAvatarCaptureProps = {
disabled?: boolean;
testIdPrefix: string;
/** Receives the composed animated avatar URL after upload. */
onApply: (avatarUrl: string) => void;
/**
* Host element inside the page's main avatar preview. When provided, the
* camera feed, recording ring, and composed preview render there (via a
* portal) instead of inside the tab so edits show exactly where the
* avatar will live. Pair with `onPreviewActiveChange` so the host can
* hide its regular preview while the capture content is showing.
*/
previewContainer?: HTMLElement | null;
onPreviewActiveChange?: (active: boolean) => void;
onPreviewCaptionChange?: (caption: string | null) => void;
onApplyPendingChange?: (isPending: boolean) => void;
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
/**
* Receives the current apply function (or null when there is nothing to
* apply) so the host's Done button can upload-and-apply in one step.
*/
registerApply?: (apply: (() => Promise<boolean>) | null) => void;
/** Show the in-tab "Use as avatar" button (hosts without a Done button). */
showApplyButton?: boolean;
};
export function AnimatedAvatarCapture({
disabled = false,
testIdPrefix,
@@ -107,6 +79,8 @@ export function AnimatedAvatarCapture({
onPreviewCaptionChange,
registerApply,
showApplyButton = true,
autoStartCamera = false,
compactReview = false,
}: AnimatedAvatarCaptureProps) {
const [phase, setPhase] = React.useState<CapturePhase>("idle");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
@@ -135,9 +109,10 @@ export function AnimatedAvatarCapture({
const [personOutline, setPersonOutline] = React.useState(
DEFAULT_PERSON_OUTLINE,
);
const initialShapeOffsetY = compactReview ? -24 : DEFAULT_SHAPE_OFFSET_Y;
const [shapeOffset, setShapeOffset] = React.useState({
x: DEFAULT_SHAPE_OFFSET_X,
y: DEFAULT_SHAPE_OFFSET_Y,
y: initialShapeOffsetY,
});
const [shapeScale, setShapeScale] = React.useState(DEFAULT_SHAPE_SCALE);
const [activeSection, setActiveSection] =
@@ -164,7 +139,7 @@ export function AnimatedAvatarCapture({
const resetActiveFraming = React.useCallback(() => {
if (activeSection === "shape") {
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: DEFAULT_SHAPE_OFFSET_Y });
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: initialShapeOffsetY });
setShapeScale(DEFAULT_SHAPE_SCALE);
return;
}
@@ -173,7 +148,7 @@ export function AnimatedAvatarCapture({
y: DEFAULT_PERSON_OFFSET_Y,
});
setPersonScale(DEFAULT_PERSON_SCALE);
}, [activeSection]);
}, [activeSection, initialShapeOffsetY]);
const resetAllFraming = React.useCallback(() => {
setPersonOffset({
@@ -181,9 +156,9 @@ export function AnimatedAvatarCapture({
y: DEFAULT_PERSON_OFFSET_Y,
});
setPersonScale(DEFAULT_PERSON_SCALE);
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: DEFAULT_SHAPE_OFFSET_Y });
setShapeOffset({ x: DEFAULT_SHAPE_OFFSET_X, y: initialShapeOffsetY });
setShapeScale(DEFAULT_SHAPE_SCALE);
}, []);
}, [initialShapeOffsetY]);
// Custom backdrop color picker (shared HSV panel).
const [isCustomPickerOpen, setIsCustomPickerOpen] = React.useState(false);
@@ -278,8 +253,6 @@ export function AnimatedAvatarCapture({
releaseCamera();
};
}, [releaseCamera]);
// Close bitmaps on unmount only — phase changes manage them explicitly.
React.useEffect(() => releaseBitmaps, [releaseBitmaps]);
React.useEffect(() => {
@@ -435,6 +408,28 @@ export function AnimatedAvatarCapture({
[cameraDevices, phase, startCamera],
);
React.useEffect(() => {
if (!autoStartCamera || phase !== "idle" || selectedCameraSource) {
return;
}
const computerCamera = preferredCameraDevice(cameraDevices, "computer");
const iphoneCamera = preferredCameraDevice(cameraDevices, "iphone");
const source: CameraSource =
computerCamera || !iphoneCamera ? "computer" : "iphone";
const cameraId =
(source === "computer" ? computerCamera : iphoneCamera)?.deviceId ?? null;
setSelectedCameraSource(source);
setSelectedCameraId(cameraId);
void startCamera(cameraId, source);
}, [
autoStartCamera,
cameraDevices,
phase,
selectedCameraSource,
startCamera,
]);
const record = React.useCallback(async () => {
const video = videoRef.current;
if (!video || phase !== "live") {
@@ -576,10 +571,6 @@ export function AnimatedAvatarCapture({
);
const isFramingSection =
activeSection === "person" || activeSection === "shape";
// With a host preview container, the camera feed / recording ring /
// composed preview render inside the page's main avatar preview (via a
// portal) so edits show exactly where the avatar will live.
const usePortal = previewContainer !== null;
const reviewWarning =
phase === "review" && recording && !recording.backgroundRemoved
@@ -608,7 +599,9 @@ export function AnimatedAvatarCapture({
? null
: captureHelpText;
const showCaptureCard = !usePortal && phase !== "review";
const showCameraPicker = ["idle", "starting", "live"].includes(phase);
const showInlineReviewStage = !usePortal && phase === "review";
const showCameraControls = ["idle", "starting", "live"].includes(phase);
const showCameraPicker = !autoStartCamera && showCameraControls;
React.useEffect(() => {
onPreviewActiveChange?.(usePortal);
@@ -635,7 +628,16 @@ export function AnimatedAvatarCapture({
}, [isCustomPickerVisible, onCustomColorPickerOpenChange]);
const stageContent = (
<div className={cn("relative", usePortal ? "h-full w-full" : "h-44 w-44")}>
<div
className={cn(
"relative",
usePortal
? "h-full w-full"
: compactReview && phase === "review"
? "h-36 w-36"
: "h-44 w-44",
)}
>
{/* Live camera preview — kept mounted so the stream can attach. */}
<div
className={cn(
@@ -646,7 +648,7 @@ export function AnimatedAvatarCapture({
<video
autoPlay
className={cn(
"h-full w-full -scale-x-100 object-cover",
"block h-full w-full -scale-x-100 object-cover object-center",
!isCameraVisible && "opacity-0",
)}
data-testid={`${testIdPrefix}-animated-preview`}
@@ -794,8 +796,12 @@ export function AnimatedAvatarCapture({
<div
className={cn(
"relative grid content-start",
phase === "review" ? "gap-7 pb-9 pt-2" : "gap-4 pb-5",
phase === "review" && !showApplyButton && "mb-5",
phase === "review"
? compactReview
? "gap-4 pb-2 pt-0"
: "gap-7 pb-9 pt-2"
: "gap-4 pb-5",
phase === "review" && !showApplyButton && !compactReview && "mb-5",
isCustomPickerVisible && "min-h-[504px]",
)}
data-testid={`${testIdPrefix}-animated`}
@@ -804,6 +810,10 @@ export function AnimatedAvatarCapture({
? createPortal(stageContent, previewContainer)
: null}
{showInlineReviewStage ? (
<div className="grid place-items-center">{stageContent}</div>
) : null}
{showCaptureCard ? (
<div className="relative grid place-items-center rounded-xl bg-muted px-4 py-6">
{usePortal ? null : stageContent}
@@ -845,7 +855,12 @@ export function AnimatedAvatarCapture({
) : null}
{phase === "review" && isFramingSection ? (
<div className="flex items-start gap-2">
<div
className={cn(
"flex items-start gap-2",
compactReview && "mx-auto w-full max-w-[360px]",
)}
>
<AvatarFramingSlider
disabled={disabled || isSaving}
max={Math.round(activeScaleMax * 100)}
@@ -877,6 +892,7 @@ export function AnimatedAvatarCapture({
{phase === "review" && activeSection === "color" ? (
<AnimatedAvatarBackdropPanel
backdropColor={backdropColor}
compact={compactReview}
disabled={disabled}
isCustomBackdropSelected={isCustomBackdropSelected}
isSaving={isSaving}
@@ -901,45 +917,28 @@ export function AnimatedAvatarCapture({
/>
) : null}
{showCameraPicker ? (
<div className="grid gap-4">
<AnimatedAvatarCameraPicker
activeCameraSource={activeCameraSource}
computerDisabled={cameraDevices.length > 0 && !computerCamera}
disabled={disabled || phase === "starting"}
iphoneDisabled={
cameraDevices.length > 0 && !iphoneCamera && hasCameraLabels
}
onSelectSource={selectCameraSource}
testIdPrefix={testIdPrefix}
/>
{usePortal && inlineCaptureHelpText ? (
<p className="px-1 text-center text-sm text-muted-foreground">
{inlineCaptureHelpText}
</p>
) : null}
<div className="h-14 pt-2">
{phase === "live" ? (
<Button
asChild
className="h-12 w-full rounded-xl"
data-testid={`${testIdPrefix}-animated-record`}
disabled={disabled}
onClick={() => void record()}
type="button"
>
<motion.button
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={ENTRANCE_TRANSITION}
>
<Video aria-hidden="true" className="mr-2 h-4 w-4" />
Record {RECORD_SECONDS} seconds
</motion.button>
</Button>
) : null}
</div>
</div>
{showCameraControls ? (
<AnimatedAvatarCameraControls
activeCameraSource={activeCameraSource}
compact={compactReview}
computerDisabled={cameraDevices.length > 0 && !computerCamera}
disabled={disabled}
helpText={usePortal ? inlineCaptureHelpText : null}
iphoneDisabled={
cameraDevices.length > 0 && !iphoneCamera && hasCameraLabels
}
isLive={phase === "live"}
isStarting={phase === "starting"}
onRecord={() => void record()}
onRetry={
errorMessage && autoStartCamera && phase === "idle"
? () => void startCamera(selectedCameraId, selectedCameraSource)
: undefined
}
onSelectSource={selectCameraSource}
showCameraPicker={showCameraPicker}
testIdPrefix={testIdPrefix}
/>
) : usePortal && inlineCaptureHelpText ? (
<p className="px-1 text-center text-sm text-muted-foreground">
{inlineCaptureHelpText}
@@ -0,0 +1,14 @@
export type AnimatedAvatarCaptureProps = {
disabled?: boolean;
testIdPrefix: string;
onApply: (avatarUrl: string) => void;
previewContainer?: HTMLElement | null;
onPreviewActiveChange?: (active: boolean) => void;
onPreviewCaptionChange?: (caption: string | null) => void;
onApplyPendingChange?: (isPending: boolean) => void;
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
registerApply?: (apply: (() => Promise<boolean>) | null) => void;
showApplyButton?: boolean;
autoStartCamera?: boolean;
compactReview?: boolean;
};
@@ -3,16 +3,16 @@ import Picker from "@emoji-mart/react";
import { Link2, UploadCloud } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal, flushSync } from "react-dom";
import { flushSync } from "react-dom";
import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCapture";
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
import { ProfileAvatarModeTabs } from "@/features/profile/ui/ProfileAvatarModeTabs";
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { useEmojiBurst } from "@/shared/ui/EmojiBurstProvider";
import { Spinner } from "@/shared/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import {
AVATAR_COLORS,
AVATAR_COLOR_SWATCHES,
@@ -33,12 +33,13 @@ import {
useEmojiMartStyles,
useEmojiMartThemeVars,
} from "./ProfileAvatarEditor.utils";
export { parseEmojiAvatarDataUrl } from "./ProfileAvatarEditor.utils";
export type { AvatarMode } from "./ProfileAvatarEditor.types";
import type {
AvatarMode,
ProfileAvatarEditorProps,
} from "./ProfileAvatarEditor.types";
export type AvatarMode = "image" | "emoji" | "animated";
const MODE_TAB_ORDER: AvatarMode[] = ["image", "emoji", "animated"];
const DONE_BUTTON_CONTENT_TRANSITION = {
duration: 0.14,
ease: [0.23, 1, 0.32, 1],
@@ -59,40 +60,11 @@ function waitForPendingButtonPaint() {
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
setTimeout(resolve, 0);
});
window.requestAnimationFrame(() => setTimeout(resolve, 0));
});
});
}
type ProfileAvatarEditorProps = {
avatarUrl: string;
previewName: string;
onUrlChange: (url: string) => void;
emojiPickerTheme?: "auto" | "dark" | "light";
emojiPickerThemeVars?: React.CSSProperties;
onEmojiAvatarChange?: () => void;
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
onModeChange?: (mode: AvatarMode) => void;
onUploadedAvatarChange?: (url: string | null) => void;
onUploadingChange?: (isUploading: boolean) => void;
onAnimatedAvatarApply?: (url: string) => void;
onDone?: () => void;
donePending?: boolean;
showEmojiColorControlsWhenEmpty?: boolean;
disabled?: boolean;
testIdPrefix?: string;
/** Host element the animated tab renders its live preview into. */
animatedPreviewContainer?: HTMLElement | null;
/** Optional host element for the mode tabs; undefined keeps them inline. */
modeTabsContainer?: HTMLElement | null;
/** Fires when the animated tab starts/stops occupying the host preview. */
onAnimatedPreviewActiveChange?: (active: boolean) => void;
/** Caption shown under the host preview while animated capture is active. */
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
};
type EmojiMartEmoji = {
native?: string;
};
@@ -132,6 +104,7 @@ export function ProfileAvatarEditor({
modeTabsContainer,
onAnimatedPreviewActiveChange,
onAnimatedPreviewCaptionChange,
presentation = "default",
}: ProfileAvatarEditorProps) {
const { burstEmoji } = useEmojiBurst();
const shouldReduceMotion = useReducedMotion();
@@ -166,11 +139,29 @@ export function ProfileAvatarEditor({
number | null
>(null);
const documentEmojiMartThemeVars = useEmojiMartThemeVars();
const emojiMartThemeVars = emojiPickerThemeVars ?? documentEmojiMartThemeVars;
const emojiMartThemeVars = React.useMemo(
() =>
({
...(emojiPickerThemeVars ?? documentEmojiMartThemeVars),
...(presentation === "onboarding-modal"
? {
"--buzz-emoji-picker-category-icon-size": "18px",
"--buzz-emoji-picker-fade-height": "56px",
"--buzz-emoji-picker-fade-opacity": "1",
"--buzz-emoji-picker-nav-button-size": "32px",
"--buzz-emoji-picker-nav-padding-x": "12px",
"--buzz-emoji-picker-padding": "10px",
"--buzz-emoji-picker-scroll-padding-top": "18px",
}
: null),
}) as React.CSSProperties,
[documentEmojiMartThemeVars, emojiPickerThemeVars, presentation],
);
const customColorDraft = React.useMemo(
() => hsvToHex(customHue, customSaturation, customValue),
[customHue, customSaturation, customValue],
);
const isOnboardingModal = presentation === "onboarding-modal";
const shouldShowColorControls =
mode === "emoji" &&
(selectedEmoji !== null || showEmojiColorControlsWhenEmpty);
@@ -268,20 +259,55 @@ export function ProfileAvatarEditor({
}, [mode, onDone]);
React.useEffect(() => {
if (!isAnimatedDoneQueued) {
return;
}
if (!isAnimatedDoneQueued) return;
setIsAnimatedDoneQueued(false);
onDone?.();
}, [isAnimatedDoneQueued, onDone]);
useEmojiMartStyles(emojiPickerContainerRef, mode === "emoji");
React.useEffect(() => {
if (mode !== "emoji") return;
let animationFrame = 0;
let observer: MutationObserver | null = null;
const syncSelectedEmojiButton = () => {
const shadowRoot =
emojiPickerContainerRef.current?.querySelector(
"em-emoji-picker",
)?.shadowRoot;
if (!shadowRoot) {
animationFrame = window.requestAnimationFrame(syncSelectedEmojiButton);
return;
}
shadowRoot
.querySelectorAll('button[data-buzz-selected="true"]')
.forEach((button) => {
button.removeAttribute("data-buzz-selected");
});
if (selectedEmoji) {
shadowRoot.querySelectorAll(".category button").forEach((button) => {
if (button.getAttribute("aria-label") === selectedEmoji) {
button.setAttribute("data-buzz-selected", "true");
}
});
}
observer ??= new MutationObserver(syncSelectedEmojiButton);
observer.observe(shadowRoot, { childList: true, subtree: true });
};
animationFrame = window.requestAnimationFrame(syncSelectedEmojiButton);
return () => {
window.cancelAnimationFrame(animationFrame);
observer?.disconnect();
};
}, [mode, selectedEmoji]);
React.useLayoutEffect(() => {
const node = modeContentRef.current;
if (!node) {
return;
}
if (!node) return;
const updateModeContentHeight = () => {
setModeContentHeight(node.getBoundingClientRect().height);
@@ -313,9 +339,7 @@ export function ProfileAvatarEditor({
}, [avatarUrl]);
React.useEffect(() => {
if (!shouldShowColorControls) {
setIsCustomColorPickerOpen(false);
}
if (!shouldShowColorControls) setIsCustomColorPickerOpen(false);
}, [shouldShowColorControls]);
React.useLayoutEffect(() => {
@@ -472,63 +496,26 @@ export function ProfileAvatarEditor({
onDone &&
!isAnyCustomColorPickerVisible &&
(mode !== "animated" || hasAnimatedApply || isDoneButtonPending);
const modeTabs = (
<Tabs
className="w-full"
onValueChange={(nextMode) => {
if (isInputDisabled) {
return;
}
updateMode(nextMode as AvatarMode);
}}
value={mode}
>
<TabsList
aria-label="Avatar type"
className="relative isolate grid h-14 w-full grid-cols-3 overflow-hidden rounded-full bg-muted p-1 text-muted-foreground"
>
<div
aria-hidden="true"
className="absolute bottom-1 left-1 top-1 z-0 rounded-full bg-background shadow transition-transform duration-[250ms] ease-out"
style={{
transform: `translateX(${MODE_TAB_ORDER.indexOf(mode) * 100}%)`,
width: "calc((100% - 8px) / 3)",
}}
/>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="image"
>
Image
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="emoji"
>
Emoji
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none"
disabled={isInputDisabled}
value="animated"
>
Animated
</TabsTrigger>
</TabsList>
</Tabs>
const isDoneButtonDisabled =
disabled ||
isDoneButtonPending ||
(isOnboardingModal && mode === "animated" && !hasAnimatedApply);
const modeTabsContent = (
<ProfileAvatarModeTabs
disabled={isInputDisabled}
mode={mode}
onModeChange={updateMode}
portalContainer={modeTabsContainer}
presentation={presentation}
/>
);
const modeTabsContent =
modeTabsContainer === undefined
? modeTabs
: modeTabsContainer
? createPortal(modeTabs, modeTabsContainer)
: null;
return (
<fieldset
className="mx-auto w-full max-w-[576px] border-0 p-0 text-sm"
className={cn(
"mx-auto w-full border-0 p-0 text-sm",
isOnboardingModal ? "max-w-[456px]" : "max-w-[576px]",
)}
data-testid={`${testIdPrefix}-editor`}
disabled={isInputDisabled}
onDragEnter={(event) => {
@@ -582,26 +569,54 @@ export function ProfileAvatarEditor({
}}
>
<legend className="sr-only">Avatar image picker</legend>
<div className="relative">
<div className="relative grid w-full gap-4">
<div
className="relative"
style={
isOnboardingModal
? { minHeight: isAnyCustomColorPickerVisible ? 704 : 454 }
: undefined
}
>
<div
className={cn(
"relative w-full",
isOnboardingModal ? "flex min-h-[inherit] flex-col" : "grid gap-4",
)}
>
{modeTabsContent}
<div
className="overflow-hidden transition-[height] duration-[250ms] ease-out"
className={cn(
"transition-[height] duration-[250ms] ease-out",
isOnboardingModal
? cn(
"flex min-h-0 flex-1 items-center overflow-visible",
shouldShowColorControls && "py-6",
)
: "overflow-hidden",
)}
data-testid={`${testIdPrefix}-mode-content-shell`}
style={
modeContentHeight === null
isOnboardingModal || modeContentHeight === null
? undefined
: { height: modeContentHeight }
}
>
<div className="overflow-visible" ref={modeContentRef}>
<div
className={cn("overflow-visible", isOnboardingModal && "w-full")}
ref={modeContentRef}
>
{mode === "image" ? (
<div className="grid content-start gap-3">
<button
className={cn(
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
isOnboardingModal
? "relative flex h-32 flex-col items-center justify-center overflow-hidden rounded-lg border border-dashed border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.7)] bg-transparent text-[rgb(var(--buzz-onboarding-avatar-control-fg))] transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-[color:rgb(var(--buzz-onboarding-avatar-accent-bg)_/_0.18)] disabled:opacity-60"
: "relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
isImageDropActive &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
(isOnboardingModal
? "border-[rgb(var(--buzz-onboarding-avatar-control-fg))] bg-[color:rgb(var(--buzz-onboarding-avatar-accent-bg)_/_0.24)]"
: "border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10"),
)}
data-dragging={isImageDropActive ? "true" : undefined}
data-testid={`${testIdPrefix}-upload`}
@@ -617,7 +632,7 @@ export function ProfileAvatarEditor({
)}
data-testid={`${testIdPrefix}-drop-mask`}
/>
{isUploading ? (
{isOnboardingModal ? null : isUploading ? (
<Spinner
aria-hidden
className="relative h-8 w-8 border-2 text-muted-foreground"
@@ -632,14 +647,22 @@ export function ProfileAvatarEditor({
)}
<span
className={cn(
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
isImageDropActive && "text-primary",
"relative transition-colors duration-[250ms] ease-out",
isOnboardingModal
? "text-sm font-normal text-[rgb(var(--buzz-onboarding-avatar-control-fg))]"
: "text-sm font-medium text-muted-foreground",
isImageDropActive &&
(isOnboardingModal
? "text-[rgb(var(--buzz-onboarding-avatar-control-fg))]"
: "text-primary"),
)}
>
{isUploading ? (
"Uploading..."
) : isImageDropActive ? (
"Drop image here"
) : isOnboardingModal ? (
"Drag or browse"
) : (
<>
Drop or{" "}
@@ -651,12 +674,26 @@ export function ProfileAvatarEditor({
</span>
</button>
<div className="flex h-16 items-center gap-3 rounded-xl bg-muted px-5 transition-colors duration-[250ms] ease-out focus-within:bg-muted/80">
<Link2 className="h-4 w-4 text-muted-foreground" />
<div
className={cn(
"flex items-center transition-colors duration-[250ms] ease-out",
isOnboardingModal
? "h-[52px] rounded-lg border border-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.45)] bg-transparent px-5 focus-within:border-[rgb(var(--buzz-onboarding-avatar-control-fg))]"
: "h-16 gap-3 rounded-xl bg-muted px-5 focus-within:bg-muted/80",
)}
>
{isOnboardingModal ? null : (
<Link2 className="h-4 w-4 text-muted-foreground" />
)}
<input
autoCapitalize="none"
autoCorrect="off"
className="min-w-0 flex-1 bg-transparent text-sm font-medium text-foreground outline-none placeholder:text-muted-foreground"
className={cn(
"min-w-0 flex-1 bg-transparent outline-none",
isOnboardingModal
? "text-center text-sm font-normal text-foreground placeholder:text-[color:rgb(var(--buzz-onboarding-avatar-control-fg)_/_0.55)]"
: "text-sm font-medium text-foreground placeholder:text-muted-foreground",
)}
data-testid={`${testIdPrefix}-url`}
disabled={isInputDisabled}
onBlur={() => {
@@ -679,7 +716,11 @@ export function ProfileAvatarEditor({
applyUrl();
}
}}
placeholder="Paste a URL (Slack profile, etc.)"
placeholder={
isOnboardingModal
? "Paste a URL"
: "Paste a URL (Slack profile, etc.)"
}
spellCheck={false}
type="url"
value={urlDraft}
@@ -708,6 +749,8 @@ export function ProfileAvatarEditor({
onPreviewCaptionChange={onAnimatedPreviewCaptionChange}
previewContainer={animatedPreviewContainer}
registerApply={registerAnimatedApply}
autoStartCamera={isOnboardingModal}
compactReview={isOnboardingModal}
showApplyButton={!onDone}
testIdPrefix={testIdPrefix}
/>
@@ -723,8 +766,8 @@ export function ProfileAvatarEditor({
data={emojiData}
dynamicWidth
emojiButtonRadius="999px"
emojiButtonSize={64}
emojiSize={48}
emojiButtonSize={isOnboardingModal ? 44 : 64}
emojiSize={isOnboardingModal ? 28 : 48}
icons="outline"
navPosition="bottom"
onEmojiSelect={(
@@ -741,7 +784,9 @@ export function ProfileAvatarEditor({
selectedEmoji === null
? randomInitialEmojiAvatarColor()
: selectedColor;
burstEmoji(emoji.native, event);
if (!isOnboardingModal) {
burstEmoji(emoji.native, event);
}
setSelectedEmoji(emoji.native);
setSelectedColor(nextColor);
applyEmojiAvatar(emoji.native, nextColor);
@@ -768,7 +813,10 @@ export function ProfileAvatarEditor({
inert={shouldShowColorControls ? undefined : true}
>
<div
className="grid grid-cols-8 justify-items-center gap-3 rounded-xl bg-muted p-4 transition-colors duration-[250ms] ease-out"
className={cn(
"grid grid-cols-8 justify-items-center rounded-xl bg-muted transition-colors duration-[250ms] ease-out",
isOnboardingModal ? "gap-2 p-3" : "gap-3 p-4",
)}
data-testid={`${testIdPrefix}-color-grid`}
>
{AVATAR_COLOR_SWATCHES.map((swatch) => {
@@ -794,7 +842,8 @@ export function ProfileAvatarEditor({
}
aria-pressed={isSelected}
className={cn(
"relative h-10 w-10 scroll-mb-52 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"relative scroll-mb-52 rounded-full border border-border transition-transform duration-200 ease-out hover:scale-[1.15] focus-visible:scale-[1.15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isOnboardingModal ? "h-7 w-7" : "h-10 w-10",
isCustomSwatch &&
!selectedEmoji &&
"cursor-not-allowed opacity-45 hover:scale-100 focus-visible:scale-100",
@@ -818,7 +867,10 @@ export function ProfileAvatarEditor({
>
{isSelected ? (
<span
className="absolute inset-1 rounded-full border-[3px]"
className={cn(
"absolute rounded-full border-[3px]",
isOnboardingModal ? "inset-0.5" : "inset-1",
)}
style={{
borderColor: contrastColorForBackground(
isCustomSwatch ? selectedColor : swatch,
@@ -853,11 +905,18 @@ export function ProfileAvatarEditor({
<AnimatePresence initial={false}>
{shouldShowDoneButton ? (
<Button asChild className="mt-2 h-12 w-full rounded-xl">
<Button
asChild
className={cn(
isOnboardingModal
? "mx-auto mt-0 h-[2.375rem] min-w-24 rounded-full bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] px-6 text-sm font-medium text-[rgb(var(--buzz-onboarding-avatar-action-fg))] hover:bg-[color:rgb(var(--buzz-onboarding-avatar-action-bg)_/_0.9)]"
: "mt-2 h-12 w-full rounded-xl",
)}
>
<motion.button
animate={{ opacity: 1, scale: 1 }}
data-testid={`${testIdPrefix}-done`}
disabled={disabled || isDoneButtonPending}
disabled={isDoneButtonDisabled}
exit={
shouldReduceMotion
? { opacity: 0 }
@@ -915,7 +974,7 @@ export function ProfileAvatarEditor({
key="ready"
transition={DONE_BUTTON_CONTENT_TRANSITION}
>
Done
{isOnboardingModal ? "Save" : "Done"}
</motion.span>
)}
</AnimatePresence>
@@ -0,0 +1,28 @@
import type * as React from "react";
export type AvatarMode = "image" | "emoji" | "animated";
export type AvatarEditorPresentation = "default" | "onboarding-modal";
export type ProfileAvatarEditorProps = {
avatarUrl: string;
previewName: string;
onUrlChange: (url: string) => void;
emojiPickerTheme?: "auto" | "dark" | "light";
emojiPickerThemeVars?: React.CSSProperties;
onEmojiAvatarChange?: () => void;
onCustomColorPickerOpenChange?: (isOpen: boolean) => void;
onModeChange?: (mode: AvatarMode) => void;
onUploadedAvatarChange?: (url: string | null) => void;
onUploadingChange?: (isUploading: boolean) => void;
onAnimatedAvatarApply?: (url: string) => void;
onDone?: () => void;
donePending?: boolean;
showEmojiColorControlsWhenEmpty?: boolean;
disabled?: boolean;
testIdPrefix?: string;
animatedPreviewContainer?: HTMLElement | null;
modeTabsContainer?: HTMLElement | null;
onAnimatedPreviewActiveChange?: (active: boolean) => void;
onAnimatedPreviewCaptionChange?: (caption: string | null) => void;
presentation?: AvatarEditorPresentation;
};
@@ -71,7 +71,7 @@ const EMOJI_MART_SHADOW_CSS = `
}
#root {
--padding: 16px;
--padding: var(--buzz-emoji-picker-padding, 16px);
--sidebar-width: 0px;
display: flex;
flex-direction: column;
@@ -79,16 +79,34 @@ const EMOJI_MART_SHADOW_CSS = `
max-height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
width: 100% !important;
}
#root::after {
background: linear-gradient(
to bottom,
rgba(var(--buzz-emoji-picker-rgb-background), 0),
rgba(var(--buzz-emoji-picker-rgb-background), 0.98)
);
bottom: calc(var(--buzz-emoji-picker-nav-button-size, 40px) + 24px);
content: "";
height: var(--buzz-emoji-picker-fade-height, 0px);
left: 0;
opacity: var(--buzz-emoji-picker-fade-opacity, 0);
pointer-events: none;
position: absolute;
right: 0;
z-index: 3;
}
.scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-left: var(--padding);
padding-right: var(--padding);
padding-top: 28px;
padding-top: var(--buzz-emoji-picker-scroll-padding-top, 28px);
width: 100%;
}
@@ -110,7 +128,17 @@ const EMOJI_MART_SHADOW_CSS = `
}
.category button .background {
background-color: rgba(255, 255, 255, 0.1);
background-color: transparent;
transition: background-color var(--duration) var(--easing);
}
.category button:hover .background,
.category button[data-buzz-selected="true"] .background {
background-color: rgba(var(--em-rgb-color), 0.14);
}
.category button[data-buzz-selected="true"] .background {
background-color: rgba(var(--em-rgb-color), 0.2);
}
.row {
@@ -122,7 +150,7 @@ const EMOJI_MART_SHADOW_CSS = `
display: flex;
flex: 0 0 auto;
justify-content: space-between;
padding: 8px 24px 16px;
padding: 8px var(--buzz-emoji-picker-nav-padding-x, 24px) 16px;
}
#nav .bar {
@@ -139,14 +167,14 @@ const EMOJI_MART_SHADOW_CSS = `
border-radius: 999px;
color: rgba(var(--em-rgb-color), 0.58);
display: flex;
flex: 0 0 40px;
height: 40px;
flex: 0 0 var(--buzz-emoji-picker-nav-button-size, 40px);
height: var(--buzz-emoji-picker-nav-button-size, 40px);
justify-content: center;
transition:
background-color var(--duration) var(--easing),
color var(--duration) var(--easing),
transform var(--duration) var(--easing);
width: 40px;
width: var(--buzz-emoji-picker-nav-button-size, 40px);
}
#nav button:hover,
@@ -164,8 +192,8 @@ const EMOJI_MART_SHADOW_CSS = `
#nav svg,
#nav img {
height: 24px;
width: 24px;
height: var(--buzz-emoji-picker-category-icon-size, 24px);
width: var(--buzz-emoji-picker-category-icon-size, 24px);
}
`;
@@ -0,0 +1,82 @@
import { createPortal } from "react-dom";
import type {
AvatarEditorPresentation,
AvatarMode,
} from "@/features/profile/ui/ProfileAvatarEditor.types";
import { cn } from "@/shared/lib/cn";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
const MODE_TAB_ORDER: AvatarMode[] = ["image", "emoji", "animated"];
const MODE_TAB_LABELS: Record<AvatarMode, string> = {
animated: "Animated",
emoji: "Emoji",
image: "Image",
};
type ProfileAvatarModeTabsProps = {
disabled: boolean;
mode: AvatarMode;
onModeChange: (mode: AvatarMode) => void;
presentation: AvatarEditorPresentation;
portalContainer?: HTMLElement | null;
};
export function ProfileAvatarModeTabs({
disabled,
mode,
onModeChange,
presentation,
portalContainer,
}: ProfileAvatarModeTabsProps) {
const isOnboardingModal = presentation === "onboarding-modal";
const tabs = (
<Tabs
className={isOnboardingModal ? "flex w-full justify-center" : "w-full"}
onValueChange={(nextMode) => {
if (!disabled) onModeChange(nextMode as AvatarMode);
}}
value={mode}
>
<TabsList
aria-label="Avatar type"
className={cn(
isOnboardingModal
? "flex h-10 w-auto gap-2 rounded-none bg-transparent p-0 text-muted-foreground"
: "relative isolate grid h-14 w-full grid-cols-3 overflow-hidden rounded-full bg-muted p-1 text-muted-foreground",
)}
>
{isOnboardingModal ? null : (
<div
aria-hidden="true"
className="absolute bottom-1 left-1 top-1 z-0 rounded-full bg-background shadow transition-transform duration-[250ms] ease-out"
style={{
transform: `translateX(${MODE_TAB_ORDER.indexOf(mode) * 100}%)`,
width: "calc((100% - 8px) / 3)",
}}
/>
)}
{MODE_TAB_ORDER.map((tabMode) => (
<TabsTrigger
className={cn(
isOnboardingModal
? "relative z-10 h-10 rounded-[6px] px-4 text-sm font-normal shadow-none transition-colors data-[state=active]:bg-[rgb(var(--buzz-onboarding-avatar-action-bg))] data-[state=active]:text-[rgb(var(--buzz-onboarding-avatar-action-fg))] data-[state=active]:shadow-none"
: "relative z-10 h-full rounded-full bg-transparent text-sm font-medium shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
)}
disabled={disabled}
key={tabMode}
value={tabMode}
>
{MODE_TAB_LABELS[tabMode]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
return portalContainer === undefined
? tabs
: portalContainer
? createPortal(tabs, portalContainer)
: null;
}
@@ -285,6 +285,13 @@
--buzz-onboarding-emoji-picker-background: 245, 245, 245;
--buzz-onboarding-emoji-picker-color: 23, 23, 23;
--buzz-onboarding-emoji-picker-input: 255, 255, 255;
--buzz-onboarding-avatar-dialog-bg: 255 255 255;
--buzz-onboarding-avatar-accent-bg: 240 240 205;
--buzz-onboarding-avatar-accent-fg: 113 113 6;
--buzz-onboarding-avatar-action-bg: 23 23 23;
--buzz-onboarding-avatar-action-fg: 240 240 205;
--buzz-onboarding-avatar-control-fg: 113 113 6;
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
.buzz-onboarding-key-text {
@@ -310,9 +317,20 @@
--buzz-onboarding-emoji-picker-background: 38, 38, 38;
--buzz-onboarding-emoji-picker-color: 250, 250, 250;
--buzz-onboarding-emoji-picker-input: 10, 10, 10;
--buzz-onboarding-avatar-dialog-bg: 24 24 20;
--buzz-onboarding-avatar-accent-bg: 211 211 163;
--buzz-onboarding-avatar-accent-fg: 24 24 20;
--buzz-onboarding-avatar-action-bg: 113 113 6;
--buzz-onboarding-avatar-action-fg: 240 240 205;
--buzz-onboarding-avatar-control-fg: 224 224 176;
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
.buzz-onboarding-neutral-theme[data-system-color-scheme="light"] {
.buzz-onboarding-neutral-theme[data-system-color-scheme="light"],
.dark
.buzz-onboarding-neutral-theme[data-system-color-scheme="light"]:not(
.buzz-startup-shell
) {
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--primary: 0 0% 9%;
@@ -329,6 +347,13 @@
--buzz-onboarding-emoji-picker-background: 245, 245, 245;
--buzz-onboarding-emoji-picker-color: 23, 23, 23;
--buzz-onboarding-emoji-picker-input: 255, 255, 255;
--buzz-onboarding-avatar-dialog-bg: 255 255 255;
--buzz-onboarding-avatar-accent-bg: 240 240 205;
--buzz-onboarding-avatar-accent-fg: 113 113 6;
--buzz-onboarding-avatar-action-bg: 23 23 23;
--buzz-onboarding-avatar-action-fg: 240 240 205;
--buzz-onboarding-avatar-control-fg: 113 113 6;
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
.buzz-onboarding-neutral-theme[data-system-color-scheme="dark"]:not(
@@ -350,6 +375,13 @@
--buzz-onboarding-emoji-picker-background: 38, 38, 38;
--buzz-onboarding-emoji-picker-color: 250, 250, 250;
--buzz-onboarding-emoji-picker-input: 10, 10, 10;
--buzz-onboarding-avatar-dialog-bg: 24 24 20;
--buzz-onboarding-avatar-accent-bg: 211 211 163;
--buzz-onboarding-avatar-accent-fg: 24 24 20;
--buzz-onboarding-avatar-action-bg: 113 113 6;
--buzz-onboarding-avatar-action-fg: 240 240 205;
--buzz-onboarding-avatar-control-fg: 224 224 176;
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
/* The key-import card is viewport-centered on ordinary windows, independent
+1 -112
View File
@@ -1,120 +1,9 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { installFakeCamera } from "../helpers/fakeCamera";
import { openSettings } from "../helpers/settings";
/**
* Stub getUserMedia with a canvas-generated stream (animated gradient with a
* moving circle) so the camera phases work headless and deterministically
* Playwright's bundled headless shell has no media capture support.
*/
function installFakeCamera(
page: import("@playwright/test").Page,
options: { cameraDelayMs?: number; holdCamera?: boolean } = {},
) {
return page.addInitScript(
(cameraOptions) => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
const context = canvas.getContext("2d");
let hue = 0;
setInterval(() => {
if (!context) {
return;
}
hue = (hue + 7) % 360;
context.fillStyle = `hsl(${hue} 80% 60%)`;
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(
canvas.width / 2 + Math.sin(hue / 30) * 60,
canvas.height / 2,
90,
0,
Math.PI * 2,
);
context.fill();
}, 90);
const stream = canvas.captureStream(15);
const mediaDevices = navigator.mediaDevices ?? ({} as MediaDevices);
if (!navigator.mediaDevices) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: mediaDevices,
});
}
const devices: MediaDeviceInfo[] = [
{
deviceId: "builtin-camera",
groupId: "mac",
kind: "videoinput",
label: "FaceTime HD Camera",
toJSON() {
return this;
},
} as MediaDeviceInfo,
{
deviceId: "iphone-continuity",
groupId: "iphone",
kind: "videoinput",
label: "Kenny's iPhone Camera",
toJSON() {
return this;
},
} as MediaDeviceInfo,
];
const testWindow = window as Window & {
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
};
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__ = [];
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ = 0;
let releaseCamera: (() => void) | null = null;
testWindow.__BUZZ_E2E_RELEASE_CAMERA__ = () => {
releaseCamera?.();
releaseCamera = null;
};
Object.defineProperty(mediaDevices, "enumerateDevices", {
configurable: true,
value: () => Promise.resolve(devices),
});
Object.defineProperty(mediaDevices, "addEventListener", {
configurable: true,
value: () => {},
});
Object.defineProperty(mediaDevices, "removeEventListener", {
configurable: true,
value: () => {},
});
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async (constraints: MediaStreamConstraints) => {
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__?.push(constraints);
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ =
(testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0) + 1;
if (cameraOptions.holdCamera) {
await new Promise<void>((resolve) => {
releaseCamera = resolve;
});
} else if (cameraOptions.cameraDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, cameraOptions.cameraDelayMs),
);
}
return Promise.resolve(stream);
},
});
},
{
cameraDelayMs: options.cameraDelayMs ?? 0,
holdCamera: options.holdCamera ?? false,
},
);
}
// The review editor (preview + framing + poster strip + backdrop panel) is
// taller than the default 720px viewport — raise it so the whole editor remains visible.
test.use({ viewport: { height: 1280, width: 1280 } });
+1 -1
View File
@@ -303,7 +303,7 @@ test("Welcome failure retries once before allowing starter channel setup to be s
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
await page.getByLabel("Community display name").fill("Tyler");
await page.getByLabel("Community username").fill("Tyler");
await page.getByTestId("community-profile-next").click();
await enterButton.click();
+184 -1
View File
@@ -3,6 +3,7 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
import { npubEncode, nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { installFakeCamera } from "../helpers/fakeCamera";
import {
E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
seedActiveIdentity,
@@ -967,6 +968,7 @@ test("connected first-community profile step cannot discard resumable onboarding
relayUrl: "wss://default.example.com",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
@@ -977,6 +979,7 @@ test("connected first-community profile step cannot discard resumable onboarding
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
await installFakeCamera(page, { failRequests: 1 });
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
@@ -987,6 +990,7 @@ test("connected first-community profile step cannot discard resumable onboarding
await expect(
page.getByRole("heading", { name: "Build your profile" }),
).toBeVisible();
const profileMain = page.getByTestId("community-profile-main");
const profileHeading = page.getByRole("heading", {
name: "Build your profile",
});
@@ -1008,18 +1012,197 @@ test("connected first-community profile step cannot discard resumable onboarding
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
borderColor: styles.borderColor,
borderRadius: styles.borderRadius,
boxShadow: styles.boxShadow,
fontSize: styles.fontSize,
};
});
expect(nameKeyStyles.backgroundColor).toMatch(
/^(rgba\(255, 255, 255, 0\.95\)|oklab\(.+ \/ 0\.95\))$/,
);
expect(nameKeyStyles.borderColor).toBe("rgba(113, 113, 6, 0.28)");
expect(nameKeyStyles.boxShadow).toContain(
"rgba(113, 113, 6, 0.5) 0px 0px 0px 1px inset",
);
expect(nameKeyStyles).toMatchObject({
borderRadius: "16px",
fontSize: "14px",
});
await expect(page.getByText("Your name", { exact: true })).toBeVisible();
await expect(page.getByText("Your username", { exact: true })).toBeVisible();
await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute(
"data-system-color-scheme",
/^(light|dark)$/,
);
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.getByTestId("community-onboarding-flow")).toHaveAttribute(
"data-system-color-scheme",
"dark",
);
await avatarButton.click();
const avatarDialog = page.getByRole("dialog", { name: "Edit your avatar" });
await expect(avatarDialog).toBeVisible();
await expect(avatarDialog).toHaveAttribute(
"data-system-color-scheme",
"light",
);
const dialogStyles = await avatarDialog.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backgroundColor: styles.backgroundColor,
boxShadow: styles.boxShadow,
color: styles.color,
};
});
expect(dialogStyles.backgroundColor).toBe("rgb(255, 255, 255)");
expect(dialogStyles.color).toBe("rgb(23, 23, 23)");
expect(dialogStyles.boxShadow).not.toBe("none");
const dialogOverlay = page.getByTestId("dialog-overlay");
const overlayStyles = await dialogOverlay.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backdropFilter: styles.backdropFilter,
backgroundColor: styles.backgroundColor,
};
});
expect(overlayStyles.backgroundColor).toBe("rgba(0, 0, 0, 0)");
expect(overlayStyles.backdropFilter).toBe("none");
const dialogLayout = await avatarDialog.evaluate((element) => ({
clientHeight: element.clientHeight,
clientWidth: element.clientWidth,
scrollHeight: element.scrollHeight,
}));
const editorWidth = await page
.getByTestId("community-avatar-editor")
.evaluate((element) => element.clientWidth);
const uploadHeight = await page
.getByTestId("community-avatar-upload")
.evaluate((element) => element.clientHeight);
const urlBox = await page.getByTestId("community-avatar-url").boundingBox();
const dialogBox = await avatarDialog.boundingBox();
if (!dialogBox || !urlBox) {
throw new Error("Could not measure avatar dialog layout");
}
expect(dialogLayout.clientWidth).toBeLessThanOrEqual(560);
const imageDialogHeight = dialogLayout.clientHeight;
const dialogTransition = await avatarDialog.evaluate(
(element) => window.getComputedStyle(element).transitionProperty,
);
expect(dialogTransition).toContain("height");
expect(editorWidth).toBe(456);
expect(uploadHeight).toBe(126);
expect(dialogLayout.scrollHeight).toBeLessThanOrEqual(
dialogLayout.clientHeight,
);
expect(urlBox.y).toBeGreaterThanOrEqual(dialogBox.y);
expect(urlBox.y + urlBox.height).toBeLessThanOrEqual(
dialogBox.y + dialogBox.height,
);
const saveButton = page.getByTestId("community-avatar-done");
const modeContentShell = page.getByTestId(
"community-avatar-mode-content-shell",
);
await page.waitForTimeout(300);
const measureAnchoredEditorLayout = async () => {
const [tabsBox, contentShellBox, contentBox, saveBox] = await Promise.all([
page.getByRole("tablist", { name: "Avatar type" }).boundingBox(),
modeContentShell.boundingBox(),
modeContentShell.locator(":scope > div").boundingBox(),
saveButton.boundingBox(),
]);
if (!tabsBox || !contentShellBox || !contentBox || !saveBox) {
throw new Error("Could not measure anchored avatar editor layout");
}
return { tabsBox, contentShellBox, contentBox, saveBox };
};
const imageEditorLayout = await measureAnchoredEditorLayout();
expect(
Math.abs(
imageEditorLayout.contentBox.y +
imageEditorLayout.contentBox.height / 2 -
(imageEditorLayout.contentShellBox.y +
imageEditorLayout.contentShellBox.height / 2),
),
).toBeLessThanOrEqual(1);
const saveStyles = await saveButton.evaluate((element) => {
const styles = window.getComputedStyle(element);
return { backgroundColor: styles.backgroundColor, color: styles.color };
});
expect(saveStyles).toEqual({
backgroundColor: "rgb(23, 23, 23)",
color: "rgb(240, 240, 205)",
});
const defaultDialogHeight = imageDialogHeight;
await page.getByRole("tab", { name: "Emoji" }).click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBe(defaultDialogHeight);
await page.waitForTimeout(300);
const emojiEditorLayout = await measureAnchoredEditorLayout();
expect(emojiEditorLayout.saveBox.y).toBe(imageEditorLayout.saveBox.y);
await page.getByRole("tab", { name: "Animated" }).click();
await expect(saveButton).toHaveCount(0);
await expect(
page.getByTestId("community-avatar-animated-error"),
).toContainText("Could not access the camera");
const retryCameraButton = page.getByTestId("community-avatar-animated-retry");
await expect(retryCameraButton).toHaveText("Try camera again");
await retryCameraButton.click();
const captureButton = page.getByTestId("community-avatar-animated-record");
await expect(captureButton).toHaveText("Capture 3 sec video");
await captureButton.click();
await expect(
page.getByTestId("community-avatar-animated-sections"),
).toBeVisible({ timeout: 60_000 });
await expect(saveButton).toBeVisible();
await page.getByRole("tab", { name: "Emoji" }).click();
await selectFirstEmojiFromPicker(page);
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBeGreaterThan(defaultDialogHeight);
await page.waitForTimeout(300);
const selectedEmojiDialogHeight = await avatarDialog.evaluate(
(element) => element.clientHeight,
);
const expandedEmojiLayout = await measureAnchoredEditorLayout();
expect(
expandedEmojiLayout.contentBox.y - expandedEmojiLayout.contentShellBox.y,
).toBeGreaterThanOrEqual(24);
expect(
expandedEmojiLayout.contentShellBox.y +
expandedEmojiLayout.contentShellBox.height -
(expandedEmojiLayout.contentBox.y +
expandedEmojiLayout.contentBox.height),
).toBeGreaterThanOrEqual(24);
expect(
expandedEmojiLayout.contentBox.y -
(expandedEmojiLayout.tabsBox.y + expandedEmojiLayout.tabsBox.height),
).toBeGreaterThanOrEqual(24);
expect(
expandedEmojiLayout.saveBox.y -
(expandedEmojiLayout.contentBox.y +
expandedEmojiLayout.contentBox.height),
).toBeGreaterThanOrEqual(24);
await page.getByTestId("community-avatar-custom-color").click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBeGreaterThan(selectedEmojiDialogHeight);
await page.getByTestId("community-avatar-custom-color-done").click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBe(selectedEmojiDialogHeight);
await page.getByRole("tab", { name: "Image" }).click();
await expect
.poll(() => avatarDialog.evaluate((element) => element.clientHeight))
.toBe(imageDialogHeight);
await expect(profileMain).toHaveClass(/opacity-45/);
await expect(profileMain).toHaveClass(/blur-\[3px\]/);
await expect(
page.getByTestId("community-profile-name-key"),
).not.toBeFocused();
await page.keyboard.press("Escape");
await expect(avatarDialog).toHaveCount(0);
await expect(avatarButton).toBeFocused();
await expect(page.getByTestId("community-profile-next")).toHaveText("Next");
await expect(page.getByTestId("community-profile-next")).toBeDisabled();
await expect(page.getByTestId("community-profile-back")).toHaveCount(0);
+124
View File
@@ -0,0 +1,124 @@
import type { Page } from "@playwright/test";
/**
* Stub getUserMedia with a canvas-generated stream (animated gradient with a
* moving circle) so the camera phases work headless and deterministically
* Playwright's bundled headless shell has no media capture support.
*/
export function installFakeCamera(
page: Page,
options: {
cameraDelayMs?: number;
failRequests?: number;
holdCamera?: boolean;
} = {},
) {
return page.addInitScript(
(cameraOptions) => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 480;
const context = canvas.getContext("2d");
let hue = 0;
setInterval(() => {
if (!context) {
return;
}
hue = (hue + 7) % 360;
context.fillStyle = `hsl(${hue} 80% 60%)`;
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(
canvas.width / 2 + Math.sin(hue / 30) * 60,
canvas.height / 2,
90,
0,
Math.PI * 2,
);
context.fill();
}, 90);
const stream = canvas.captureStream(15);
const mediaDevices = navigator.mediaDevices ?? ({} as MediaDevices);
if (!navigator.mediaDevices) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: mediaDevices,
});
}
const devices: MediaDeviceInfo[] = [
{
deviceId: "builtin-camera",
groupId: "mac",
kind: "videoinput",
label: "FaceTime HD Camera",
toJSON() {
return this;
},
} as MediaDeviceInfo,
{
deviceId: "iphone-continuity",
groupId: "iphone",
kind: "videoinput",
label: "Kenny's iPhone Camera",
toJSON() {
return this;
},
} as MediaDeviceInfo,
];
const testWindow = window as Window & {
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
};
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__ = [];
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ = 0;
let releaseCamera: (() => void) | null = null;
testWindow.__BUZZ_E2E_RELEASE_CAMERA__ = () => {
releaseCamera?.();
releaseCamera = null;
};
Object.defineProperty(mediaDevices, "enumerateDevices", {
configurable: true,
value: () => Promise.resolve(devices),
});
Object.defineProperty(mediaDevices, "addEventListener", {
configurable: true,
value: () => {},
});
Object.defineProperty(mediaDevices, "removeEventListener", {
configurable: true,
value: () => {},
});
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async (constraints: MediaStreamConstraints) => {
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__?.push(constraints);
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ =
(testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0) + 1;
if (
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ <=
cameraOptions.failRequests
) {
throw new DOMException("Camera access denied", "NotAllowedError");
}
if (cameraOptions.holdCamera) {
await new Promise<void>((resolve) => {
releaseCamera = resolve;
});
} else if (cameraOptions.cameraDelayMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, cameraOptions.cameraDelayMs),
);
}
return Promise.resolve(stream);
},
});
},
{
cameraDelayMs: options.cameraDelayMs ?? 0,
failRequests: options.failRequests ?? 0,
holdCamera: options.holdCamera ?? false,
},
);
}