-
-
{
- setNsecInput(e.target.value);
- setError(null);
- }}
- placeholder="nsec1… (or drop a .key file)"
- spellCheck={false}
- type="password"
- value={nsecInput}
- />
-
-
- Drop a `.key` file anywhere in this box, or paste your nsec.
+
+
+ First, let's start with your name
+
+
+ Enter a nickname or whatever you want people to call you.
- {/* Live preview of the resolved npub once the input is valid. */}
- {isValid && previewNpub ? (
-
-
-
-
- This will switch your identity to:
-
-
- {shortenNpub(previewNpub)}
-
-
-
- ) : null}
-
- {showInvalidHint && !error ? (
-
- Waiting for a valid `nsec1…` key.
-
- ) : null}
-
- {error ?
{error}
: null}
-
-
-
-
-
-
- );
-}
-
-export function ProfileStep({ actions, state }: ProfileStepProps) {
- const {
- advanceWithoutSaving,
- clearAvatarDraft,
- importIdentity,
- onUploadingChange,
- skipForNow,
- submit,
- updateAvatarUrl,
- updateDisplayName,
- } = actions;
- const {
- avatar,
- currentNpub,
- isUploadingAvatar,
- isSaving,
- name,
- saveRecovery,
- } = state;
- const { draftValue: displayNameDraft, savedValue: savedDisplayName } = name;
- const canSubmit =
- displayNameDraft.trim().length > 0 && !isSaving && !isUploadingAvatar;
- const avatarPreviewLabel =
- displayNameDraft.trim() || savedDisplayName || "You";
-
- return (
-
-
-
First run
-
-
- Set up your profile
-
-
-
-
-
-
-
-
-
+
Name
+
+ {!hasDisplayNameDraft ? (
+
+
+
+ Name
+
+
+ ) : null}
+
-
+
-
0 && avatar.draftUrl !== avatar.savedUrl
- }
- disabled={isSaving}
- idleHint=""
- testIdPrefix="onboarding-avatar"
- />
+ {saveRecovery.errorMessage ? (
+
+ ) : null}
-
-
-
-
-
- {currentNpub ? (
- <>
-
- {shortenNpub(currentNpub)}
-
-
- >
- ) : null}
-
- {saveRecovery.canSkipForNow ? (
-
- ) : null}
- {saveRecovery.canAdvanceWithoutSaving ? (
-
- ) : null}
+
+
+ {back ? (
+
+ ) : null}
+
+
+
+ {saveRecovery.canSkipForNow ? (
+
+ ) : null}
+ {saveRecovery.canAdvanceWithoutSaving ? (
+
+ ) : null}
+
+
-
+
);
}
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index 5507ba64a4..e6f9fd0b1c 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -1,146 +1,439 @@
-import { TerminalSquare } from "lucide-react";
+import * as React from "react";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import {
+ AlertTriangle,
+ Check,
+ ExternalLink,
+ Plus,
+ TerminalSquare,
+} from "lucide-react";
-import { useAvailableAcpRuntimes } from "@/features/agents/hooks";
+import {
+ useAcpRuntimesQuery,
+ useInstallAcpRuntimeMutation,
+} from "@/features/agents/hooks";
+import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
+import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
+import { Spinner } from "@/shared/ui/spinner";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "./OnboardingSlideTransition";
import type { SetupStepActions, SetupStepState } from "./types";
type SetupStepProps = {
actions: SetupStepActions;
+ direction: OnboardingTransitionDirection;
};
type SetupStepContentProps = {
actions: SetupStepActions;
+ direction: OnboardingTransitionDirection;
state: SetupStepState;
};
+type InstallResultState = {
+ error: string | null;
+ success: boolean;
+};
+
function useSetupStepState(): SetupStepState {
- const providersQuery = useAvailableAcpRuntimes();
- const items = providersQuery.data ?? [];
- const isChecking = providersQuery.isLoading;
+ const runtimesQuery = useAcpRuntimesQuery();
+ const items = runtimesQuery.data ?? [];
+ const isChecking = runtimesQuery.isLoading;
const errorMessage =
- providersQuery.error instanceof Error ? providersQuery.error.message : null;
+ runtimesQuery.error instanceof Error ? runtimesQuery.error.message : null;
return {
runtimeProviders: {
errorMessage,
isChecking,
items,
- showSetupLaterHint:
- errorMessage === null && !isChecking && items.length === 0,
},
};
}
+function RuntimeIcon({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
+ const [imageFailed, setImageFailed] = React.useState(false);
+ const shouldForceForegroundColor = runtime.id === "goose";
+
+ if (runtime.avatarUrl && !imageFailed) {
+ return (
+
+
![]()
setImageFailed(true)}
+ src={runtime.avatarUrl}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+function RuntimeStatus({
+ installError,
+ installSuccess,
+ isInstalling,
+ onInstall,
+ runtime,
+}: {
+ installError: string | null;
+ installSuccess: boolean;
+ isInstalling: boolean;
+ onInstall: () => void;
+ runtime: AcpRuntimeCatalogEntry;
+}) {
+ if (isInstalling) {
+ return (
+
+
+
+ );
+ }
+
+ if (installError) {
+ return (
+
+ );
+ }
+
+ if (runtime.availability === "available" || installSuccess) {
+ return (
+
+
+
+ );
+ }
+
+ if (runtime.canAutoInstall) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
+ if (
+ runtime.availability === "available" &&
+ runtime.command &&
+ runtime.binaryPath
+ ) {
+ return (
+ <>
+
+ {describeResolvedCommand(runtime.command, runtime.binaryPath)}
+
+ {runtime.defaultArgs.length > 0 ? (
+
+ Args:{" "}
+ {runtime.defaultArgs.join(", ")}
+
+ ) : null}
+ >
+ );
+ }
+
+ if (runtime.availability === "adapter_missing") {
+ return (
+ <>
+
+ CLI detected; ACP adapter missing.
+
+
+ {runtime.installHint}
+
+ >
+ );
+ }
+
+ if (runtime.availability === "cli_missing") {
+ return (
+ <>
+
+ ACP adapter detected; CLI missing.
+
+
+ {runtime.installHint}
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ Not installed yet.
+
+
+ {runtime.installHint}
+
+ >
+ );
+}
+
+function RuntimeCard({
+ installError,
+ installSuccess,
+ isInstalling,
+ onInstall,
+ runtime,
+}: {
+ installError: string | null;
+ installSuccess: boolean;
+ isInstalling: boolean;
+ onInstall: () => void;
+ runtime: AcpRuntimeCatalogEntry;
+}) {
+ const isAvailable = runtime.availability === "available" || installSuccess;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {runtime.label}
+
+ {isAvailable ? (
+
+ Installed
+
+ ) : null}
+
+
+
+
+
+ {installError ? (
+
+ {installError}
+
+ ) : null}
+
+ {installSuccess && runtime.availability !== "available" ? (
+
+ Installed successfully. You can finish onboarding now.
+
+ ) : null}
+
+ );
+}
+
+function getInstallErrorMessage(result: {
+ steps: { stderr: string; stdout: string; step: string }[];
+}) {
+ const lastStep = result.steps[result.steps.length - 1];
+ if (!lastStep) {
+ return "Install failed with no output.";
+ }
+
+ return `Step "${lastStep.step}" failed: ${
+ lastStep.stderr || lastStep.stdout || "unknown error"
+ }`;
+}
+
function RuntimeProvidersSection({
runtimeProviders,
}: {
runtimeProviders: SetupStepState["runtimeProviders"];
}) {
- const { errorMessage, isChecking, items, showSetupLaterHint } =
- runtimeProviders;
+ const { errorMessage, isChecking, items } = runtimeProviders;
+ const installMutation = useInstallAcpRuntimeMutation();
+ const [installResults, setInstallResults] = React.useState<
+ Record
+ >({});
+
+ function handleInstall(runtimeId: string) {
+ setInstallResults((current) => ({
+ ...current,
+ [runtimeId]: { error: null, success: false },
+ }));
+
+ installMutation.mutate(runtimeId, {
+ onSuccess: (result) => {
+ setInstallResults((current) => ({
+ ...current,
+ [runtimeId]: result.success
+ ? { error: null, success: true }
+ : { error: getInstallErrorMessage(result), success: false },
+ }));
+ },
+ onError: (error) => {
+ setInstallResults((current) => ({
+ ...current,
+ [runtimeId]: {
+ error: error instanceof Error ? error.message : "Install failed.",
+ success: false,
+ },
+ }));
+ },
+ });
+ }
return (
-
-
-
-
- We only list runtimes the app can actually see on this machine.
-
-
-
- {items.length > 0 ? (
-
- {items.map((provider) => (
-
-
-
- {provider.label}
-
-
{provider.command}
-
-
- ))}
-
- ) : isChecking ? (
-
- Looking for compatible runtimes...
-
- ) : errorMessage ? null : (
-
- No compatible ACP runtimes detected yet.
-
- )}
-
- {showSetupLaterHint ? (
-
- Nothing is installed yet. That's fine. You can finish setup now
- and come back later in Settings > Doctor.
-
- ) : null}
-
- {errorMessage ? (
-
- {errorMessage}
-
- ) : null}
-
- );
-}
-
-function SetupStepContent({ actions, state }: SetupStepContentProps) {
- const { runtimeProviders } = state;
-
- return (
-
-
-
First run
-
+
+
+
- ACP runtimes
+ Agent harnesses
-
- ACP runtimes only matter when you want Sprout to launch local tools
- from this machine.
+
+ Sprout can launch local ACP-compatible agent harnesses. Install or
+ verify the runtimes this desktop app can see.
+ {items.length > 0 ? (
+
+ {items.map((runtime) => (
+ handleInstall(runtime.id)}
+ runtime={runtime}
+ />
+ ))}
+
+ ) : isChecking ? (
+
+ Looking for compatible runtimes...
+
+ ) : errorMessage ? null : (
+
+ No compatible ACP runtimes detected yet. You can finish setup now and
+ come back later in Settings > Doctor.
+
+ )}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ );
+}
+
+function SetupStepContent({
+ actions,
+ direction,
+ state,
+}: SetupStepContentProps) {
+ const { runtimeProviders } = state;
+
+ return (
+
-
-
+
+
+
-
+
);
}
-export function SetupStep({ actions }: SetupStepProps) {
+export function SetupStep({ actions, direction }: SetupStepProps) {
const state = useSetupStepState();
- return
;
+ return (
+
+ );
}
diff --git a/desktop/src/features/onboarding/ui/ThemeStep.tsx b/desktop/src/features/onboarding/ui/ThemeStep.tsx
new file mode 100644
index 0000000000..0ba54fcdc2
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/ThemeStep.tsx
@@ -0,0 +1,619 @@
+import { Check } from "lucide-react";
+import * as React from "react";
+
+import { createThemeVars, hexToHsl } from "@/shared/theme/adaptive-theme";
+import {
+ ACCENT_COLORS,
+ ACCENT_STORAGE_KEY,
+ NEUTRAL_ACCENT,
+ THEME_STORAGE_KEY,
+ useTheme,
+} from "@/shared/theme/ThemeProvider";
+import {
+ ONBOARDING_DEFAULT_THEME_NAME,
+ SYNTAX_THEMES,
+ type SyntaxThemeName,
+ extractThemeInfo,
+ isLightTheme,
+ loadThemeData,
+} from "@/shared/theme/theme-loader";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import { StepProgress } from "@/shared/ui/step-progress";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "./OnboardingSlideTransition";
+import type { ThemeStepActions } from "./types";
+
+type ThemeStepProps = {
+ actions: ThemeStepActions;
+ direction: OnboardingTransitionDirection;
+};
+
+type ThemePreviewVars = Record
;
+
+const LIGHT_PREVIEW_VARS: ThemePreviewVars = {
+ "--background": "0 0% 100%",
+ "--border": "0 0% 89.8%",
+ "--foreground": "0 0% 9%",
+ "--muted": "0 0% 96.1%",
+ "--muted-foreground": "0 0% 45.1%",
+ "--primary": "0 0% 9%",
+ "--sidebar-background": "0 0% 98%",
+ "--sidebar-foreground": "0 0% 9%",
+};
+
+const DARK_PREVIEW_VARS: ThemePreviewVars = {
+ "--background": "0 0% 3.9%",
+ "--border": "0 0% 14.9%",
+ "--foreground": "0 0% 98%",
+ "--muted": "0 0% 14.9%",
+ "--muted-foreground": "0 0% 63.9%",
+ "--primary": "0 0% 98%",
+ "--sidebar-background": "0 0% 0%",
+ "--sidebar-foreground": "0 0% 98%",
+};
+
+const GRADUAL_BLUR_LEVELS = [0.5, 1.25, 2.5, 4.5, 7, 10] as const;
+const THEME_TILE_WIDTH = 174;
+const THEME_TILE_HEIGHT = 160;
+const THEME_TILE_GAP = 12;
+const THEME_VISIBLE_ROW_COUNT = 2;
+const THEME_ROW_PEEK_HEIGHT = 48;
+const THEME_SCROLL_MAX_HEIGHT =
+ THEME_TILE_HEIGHT * THEME_VISIBLE_ROW_COUNT +
+ THEME_TILE_GAP * THEME_VISIBLE_ROW_COUNT +
+ THEME_ROW_PEEK_HEIGHT;
+
+type ThemePreviewVarsByTheme = Partial<
+ Record
+>;
+
+let themePreviewVarsCache: ThemePreviewVarsByTheme | null = null;
+let themePreviewVarsPromise: Promise | null = null;
+
+function hsl(vars: ThemePreviewVars | null, key: string) {
+ return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]})`;
+}
+
+function hslAlpha(vars: ThemePreviewVars | null, key: string, alpha: number) {
+ return `hsl(${vars?.[key] ?? LIGHT_PREVIEW_VARS[key]} / ${alpha})`;
+}
+
+function contrastColorForHex(hex: string) {
+ const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ if (!match) {
+ return "#ffffff";
+ }
+
+ const r = Number.parseInt(match[1], 16);
+ const g = Number.parseInt(match[2], 16);
+ const b = Number.parseInt(match[3], 16);
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return luminance > 0.5 ? "#000000" : "#ffffff";
+}
+
+function formatThemeLabel(name: string): string {
+ return name
+ .split("-")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
+function withAccentPreviewVars(
+ vars: ThemePreviewVars | null,
+ accentColor: string,
+): ThemePreviewVars | null {
+ if (!vars) {
+ return null;
+ }
+
+ if (accentColor === NEUTRAL_ACCENT) {
+ return {
+ ...vars,
+ "--primary": vars["--foreground"],
+ "--primary-foreground": vars["--background"],
+ };
+ }
+
+ return {
+ ...vars,
+ "--primary": hexToHsl(accentColor),
+ };
+}
+
+function getOrderedThemes() {
+ return [
+ ONBOARDING_DEFAULT_THEME_NAME,
+ ...SYNTAX_THEMES.filter((name) => name !== ONBOARDING_DEFAULT_THEME_NAME),
+ ];
+}
+
+function getThemeFallbackPreviewVars(name: SyntaxThemeName) {
+ return isLightTheme(name) ? LIGHT_PREVIEW_VARS : DARK_PREVIEW_VARS;
+}
+
+async function loadThemePreviewVars(name: SyntaxThemeName) {
+ const themeData = await loadThemeData(name);
+ const info = extractThemeInfo(name, themeData);
+ const { vars } = createThemeVars(info.bg, info.fg, info.comment, {
+ added: info.added,
+ deleted: info.deleted,
+ modified: info.modified,
+ });
+ return [name, vars] as const;
+}
+
+export function preloadThemePreviewVars() {
+ if (themePreviewVarsCache) {
+ return Promise.resolve(themePreviewVarsCache);
+ }
+
+ if (!themePreviewVarsPromise) {
+ themePreviewVarsPromise = Promise.all(
+ SYNTAX_THEMES.map((name) => loadThemePreviewVars(name)),
+ )
+ .then((entries) => {
+ const previewVars = Object.fromEntries(
+ entries,
+ ) as ThemePreviewVarsByTheme;
+ themePreviewVarsCache = previewVars;
+ return previewVars;
+ })
+ .catch((error) => {
+ themePreviewVarsPromise = null;
+ throw error;
+ });
+ }
+
+ return themePreviewVarsPromise;
+}
+
+function useThemePreviewVars() {
+ const [previewVarsByTheme, setPreviewVarsByTheme] =
+ React.useState(() => themePreviewVarsCache ?? {});
+
+ React.useEffect(() => {
+ let canceled = false;
+
+ void preloadThemePreviewVars()
+ .then((previewVars) => {
+ if (!canceled) {
+ setPreviewVarsByTheme(previewVars);
+ }
+ })
+ .catch(() => {
+ if (!canceled) {
+ setPreviewVarsByTheme({});
+ }
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, []);
+
+ return previewVarsByTheme;
+}
+
+function ThemePreviewSvg({ vars }: { vars: ThemePreviewVars | null }) {
+ const clipId = React.useId().replace(/:/g, "");
+ const background = hsl(vars, "--background");
+ const border = hsl(vars, "--border");
+ const foreground = hsl(vars, "--foreground");
+ const mutedForeground = hsl(vars, "--muted-foreground");
+ const primary = hsl(vars, "--primary");
+ const primarySoft = hslAlpha(vars, "--primary", 0.68);
+ const sidebar = hsl(vars, "--sidebar-background");
+ const sidebarForeground = hslAlpha(vars, "--sidebar-foreground", 0.58);
+
+ return (
+
+ );
+}
+
+function GradualBottomBlur() {
+ return (
+
+ {GRADUAL_BLUR_LEVELS.map((blur, index) => {
+ const transparentStop = 96 - index * 11;
+ const solidStop = Math.max(0, transparentStop - 28);
+ const maskImage = `linear-gradient(to top, black 0%, black ${solidStop}%, transparent ${transparentStop}%)`;
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function ThemePreviewFrame({
+ className,
+ vars,
+}: {
+ className?: string;
+ vars: ThemePreviewVars | null;
+}) {
+ return (
+
+ );
+}
+
+function ThemeTile({
+ isActive,
+ name,
+ onSelect,
+ vars,
+}: {
+ isActive: boolean;
+ name: SyntaxThemeName;
+ onSelect: () => void;
+ vars: ThemePreviewVars | null;
+}) {
+ return (
+
+ );
+}
+
+function AccentColorPicker({
+ accentColor,
+ onSelect,
+}: {
+ accentColor: string;
+ onSelect: (value: string) => void;
+}) {
+ return (
+
+
+ {ACCENT_COLORS.map((color) => {
+ const isNeutral = color.value === NEUTRAL_ACCENT;
+ const isSelected = accentColor === color.value;
+ const swatchBackground = isNeutral
+ ? "hsl(var(--foreground))"
+ : color.value;
+ const selectedRingColor = isNeutral
+ ? "hsl(var(--background))"
+ : contrastColorForHex(color.value);
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+export function ThemeStep({ actions, direction }: ThemeStepProps) {
+ const { skip, submit } = actions;
+ const { accentColor, setAccentColor, setTheme, themeName } = useTheme();
+ const previewVarsByTheme = useThemePreviewVars();
+ const orderedThemes = React.useMemo(() => getOrderedThemes(), []);
+
+ React.useEffect(() => {
+ const hasStoredTheme =
+ window.localStorage.getItem(THEME_STORAGE_KEY) !== null;
+ const hasStoredAccent =
+ window.localStorage.getItem(ACCENT_STORAGE_KEY) !== null;
+
+ if (!hasStoredTheme && themeName !== ONBOARDING_DEFAULT_THEME_NAME) {
+ setTheme(ONBOARDING_DEFAULT_THEME_NAME);
+ }
+
+ if (!hasStoredAccent && accentColor !== NEUTRAL_ACCENT) {
+ setAccentColor(NEUTRAL_ACCENT);
+ }
+ }, [accentColor, setAccentColor, setTheme, themeName]);
+
+ return (
+
+
+
+
+
+ Pick a theme
+
+
+ Choose a look that makes Sprout feel like yours.
+
+
+
+
+
+
+
+
+ {orderedThemes.map((name) => {
+ const vars = withAccentPreviewVars(
+ previewVarsByTheme[name] ??
+ getThemeFallbackPreviewVars(name),
+ accentColor,
+ );
+
+ return (
+ setTheme(name)}
+ vars={vars}
+ />
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts
index 6a868472b5..fa0432ebe8 100644
--- a/desktop/src/features/onboarding/ui/types.ts
+++ b/desktop/src/features/onboarding/ui/types.ts
@@ -1,6 +1,11 @@
-import type { AcpRuntime, Profile } from "@/shared/api/types";
+import type { AcpRuntimeCatalogEntry, Profile } from "@/shared/api/types";
-export type OnboardingPage = "profile" | "setup" | "membership-denied";
+export type OnboardingPage =
+ | "profile"
+ | "avatar"
+ | "theme"
+ | "setup"
+ | "membership-denied";
export type OnboardingActions = {
complete: () => void;
@@ -34,9 +39,6 @@ export type ProfileStepAvatarState = {
export type ProfileStepState = {
avatar: ProfileStepAvatarState;
- /** Bech32-encoded current pubkey (npub1…), shown so the user can confirm
- * which identity they're saving the profile for. */
- currentNpub: string | null;
isUploadingAvatar: boolean;
isSaving: boolean;
name: ProfileStepNameState;
@@ -45,8 +47,8 @@ export type ProfileStepState = {
export type ProfileStepActions = {
advanceWithoutSaving: () => void;
+ back?: () => void;
clearAvatarDraft: () => void;
- importIdentity: (nsec: string) => Promise;
onUploadingChange: (isUploading: boolean) => void;
skipForNow: () => void;
submit: () => void;
@@ -59,11 +61,15 @@ export type SetupStepActions = {
complete: () => void;
};
+export type ThemeStepActions = {
+ skip: () => void;
+ submit: () => void;
+};
+
export type SetupStepRuntimeState = {
errorMessage: string | null;
isChecking: boolean;
- items: AcpRuntime[];
- showSetupLaterHint: boolean;
+ items: AcpRuntimeCatalogEntry[];
};
export type SetupStepState = {
diff --git a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
index a3ec6a5683..7b3f30451e 100644
--- a/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
+++ b/desktop/src/features/profile/ui/ProfileAvatarEditor.tsx
@@ -1,13 +1,15 @@
import emojiData from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { Link2, Loader2, UploadCloud } from "lucide-react";
+import { motion } from "motion/react";
import * as React from "react";
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
-import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
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,
@@ -41,18 +43,23 @@ import {
export { parseEmojiAvatarDataUrl } from "./ProfileAvatarEditor.utils";
-type AvatarMode = "image" | "emoji";
+export type AvatarMode = "image" | "emoji";
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;
onDone?: () => void;
donePending?: boolean;
hiddenAvatarUrl?: string | null;
+ showEmojiColorControlsWhenEmpty?: boolean;
disabled?: boolean;
testIdPrefix?: string;
};
@@ -61,15 +68,39 @@ type EmojiMartEmoji = {
native?: string;
};
+const AVATAR_EDITOR_MOTION_TRANSITION = {
+ duration: 0.25,
+ ease: "easeOut",
+} as const;
+const INITIAL_EMOJI_AVATAR_COLORS = AVATAR_COLORS.filter(
+ (color) => color !== DEFAULT_EMOJI_AVATAR_COLOR,
+);
+
+function randomInitialEmojiAvatarColor() {
+ const colors =
+ INITIAL_EMOJI_AVATAR_COLORS.length > 0
+ ? INITIAL_EMOJI_AVATAR_COLORS
+ : AVATAR_COLORS;
+ return (
+ colors[Math.floor(Math.random() * colors.length)] ??
+ DEFAULT_EMOJI_AVATAR_COLOR
+ );
+}
+
export function ProfileAvatarEditor({
avatarUrl,
donePending = false,
+ emojiPickerTheme = "dark",
+ emojiPickerThemeVars,
hiddenAvatarUrl,
+ onCustomColorPickerOpenChange,
onEmojiAvatarChange,
+ onModeChange,
onUploadedAvatarChange,
onUrlChange,
onDone,
onUploadingChange,
+ showEmojiColorControlsWhenEmpty = false,
disabled,
testIdPrefix = "profile-avatar",
}: ProfileAvatarEditorProps) {
@@ -99,24 +130,42 @@ export function ProfileAvatarEditor({
const dragDepthRef = React.useRef(0);
const emojiPickerContainerRef = React.useRef(null);
const hueDragUserSelectRef = React.useRef(null);
+ const modeContentRef = React.useRef(null);
const isUrlInputFocusedRef = React.useRef(false);
const hasUserEditedUrlDraftRef = React.useRef(false);
- const emojiMartThemeVars = useEmojiMartThemeVars();
+ const [modeContentHeight, setModeContentHeight] = React.useState<
+ number | null
+ >(null);
+ const documentEmojiMartThemeVars = useEmojiMartThemeVars();
+ const emojiMartThemeVars = emojiPickerThemeVars ?? documentEmojiMartThemeVars;
const customColorDraft = React.useMemo(
() => hsvToHex(customHue, customSaturation, customValue),
[customHue, customSaturation, customValue],
);
- const shouldShowColorControls = mode === "emoji" && selectedEmoji !== null;
+ const shouldShowColorControls =
+ mode === "emoji" &&
+ (selectedEmoji !== null || showEmojiColorControlsWhenEmpty);
const isCustomColorPickerVisible =
isCustomColorPickerOpen && shouldShowColorControls;
+ const updateMode = React.useCallback(
+ (nextMode: AvatarMode) => {
+ if (mode === nextMode) {
+ return;
+ }
+
+ setMode(nextMode);
+ onModeChange?.(nextMode);
+ },
+ [mode, onModeChange],
+ );
const handleUploadSuccess = React.useCallback(
(uploadedUrl: string) => {
setUrlDraft("");
onUploadedAvatarChange?.(uploadedUrl);
onUrlChange(uploadedUrl);
- setMode("image");
+ updateMode("image");
},
- [onUploadedAvatarChange, onUrlChange],
+ [onUploadedAvatarChange, onUrlChange, updateMode],
);
const {
clearError: clearUploadError,
@@ -131,7 +180,25 @@ export function ProfileAvatarEditor({
useEmojiMartStyles(emojiPickerContainerRef, mode === "emoji");
- React.useEffect(() => {
+ React.useLayoutEffect(() => {
+ const node = modeContentRef.current;
+ if (!node) {
+ return;
+ }
+
+ const updateModeContentHeight = () => {
+ setModeContentHeight(node.getBoundingClientRect().height);
+ };
+
+ updateModeContentHeight();
+
+ const resizeObserver = new ResizeObserver(updateModeContentHeight);
+ resizeObserver.observe(node);
+
+ return () => resizeObserver.disconnect();
+ }, []);
+
+ React.useLayoutEffect(() => {
onUploadingChange?.(isUploading);
}, [isUploading, onUploadingChange]);
@@ -161,14 +228,28 @@ export function ProfileAvatarEditor({
}
}, [shouldShowColorControls]);
+ React.useLayoutEffect(() => {
+ onCustomColorPickerOpenChange?.(isCustomColorPickerVisible);
+
+ return () => {
+ onCustomColorPickerOpenChange?.(false);
+ };
+ }, [isCustomColorPickerVisible, onCustomColorPickerOpenChange]);
+
React.useEffect(() => {
if (!isCustomColorPickerOpen || !selectedEmoji) {
return;
}
+ const nextAvatarUrl = emojiAvatarDataUrl(selectedEmoji, customColorDraft);
+ if (avatarUrl === nextAvatarUrl) {
+ return;
+ }
+
onUploadedAvatarChange?.(null);
- onUrlChange(emojiAvatarDataUrl(selectedEmoji, customColorDraft));
+ onUrlChange(nextAvatarUrl);
}, [
+ avatarUrl,
customColorDraft,
isCustomColorPickerOpen,
onUploadedAvatarChange,
@@ -202,9 +283,9 @@ export function ProfileAvatarEditor({
}
void uploadFile(file);
- setMode("image");
+ updateMode("image");
},
- [isInputDisabled, uploadFile],
+ [isInputDisabled, updateMode, uploadFile],
);
const applyUrl = React.useCallback(() => {
@@ -218,12 +299,13 @@ export function ProfileAvatarEditor({
onUploadedAvatarChange?.(null);
onUrlChange(nextUrl);
hasUserEditedUrlDraftRef.current = false;
- setMode("image");
+ updateMode("image");
}, [
clearUploadError,
isInputDisabled,
onUploadedAvatarChange,
onUrlChange,
+ updateMode,
urlDraft,
]);
@@ -314,6 +396,9 @@ export function ProfileAvatarEditor({
}
if (swatch === CUSTOM_AVATAR_COLOR_SWATCH) {
+ if (!selectedEmoji) {
+ return;
+ }
openCustomColorPicker();
return;
}
@@ -380,7 +465,7 @@ export function ProfileAvatarEditor({
return;
}
dragDepthRef.current += 1;
- setMode("image");
+ updateMode("image");
setIsDragging(true);
}}
onDragLeave={(event) => {
@@ -404,7 +489,7 @@ export function ProfileAvatarEditor({
return;
}
event.dataTransfer.dropEffect = "copy";
- setMode("image");
+ updateMode("image");
setIsDragging(true);
}}
onDrop={(event) => {
@@ -429,7 +514,7 @@ export function ProfileAvatarEditor({
if (isInputDisabled) {
return;
}
- setMode(nextMode as AvatarMode);
+ updateMode(nextMode as AvatarMode);
}}
value={mode}
>
@@ -439,7 +524,7 @@ export function ProfileAvatarEditor({
>
-
- {mode === "image" ? (
-
-
{onDone && !isCustomColorPickerOpen ? (
@@ -835,7 +959,11 @@ export function ProfileAvatarEditor({
onClick={onDone}
type="button"
>
- {donePending ? "Saving..." : "Done"}
+ {donePending ? (
+
+ ) : (
+ "Done"
+ )}
) : null}
diff --git a/desktop/src/features/profile/useAvatarUpload.ts b/desktop/src/features/profile/useAvatarUpload.ts
index 3dba241fe1..6ada0fc242 100644
--- a/desktop/src/features/profile/useAvatarUpload.ts
+++ b/desktop/src/features/profile/useAvatarUpload.ts
@@ -1,4 +1,5 @@
import * as React from "react";
+import { flushSync } from "react-dom";
import { uploadMediaBytes } from "@/shared/api/tauri";
@@ -45,8 +46,10 @@ export function useAvatarUpload({
return;
}
- setIsUploading(true);
- setErrorMessage(null);
+ flushSync(() => {
+ setIsUploading(true);
+ setErrorMessage(null);
+ });
try {
const buffer = await file.arrayBuffer();
diff --git a/desktop/src/features/workspaces/ui/WelcomeSetup.tsx b/desktop/src/features/workspaces/ui/WelcomeSetup.tsx
index 3b996e7be6..06bafc8d91 100644
--- a/desktop/src/features/workspaces/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/workspaces/ui/WelcomeSetup.tsx
@@ -1,113 +1,616 @@
import * as React from "react";
+import { Check, KeyRound, Sprout } from "lucide-react";
+import { flushSync } from "react-dom";
-import { getIdentity } from "@/shared/api/tauri";
+import {
+ getIdentity,
+ importIdentity as tauriImportIdentity,
+} from "@/shared/api/tauri";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "@/features/onboarding/ui/OnboardingSlideTransition";
+import { cn } from "@/shared/lib/cn";
+import { nsecToNpub, shortenNpub } from "@/shared/lib/nostrUtils";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
+import { Spinner } from "@/shared/ui/spinner";
+import { StepProgress } from "@/shared/ui/step-progress";
+import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
-import { initFirstWorkspace, deriveWorkspaceName } from "../workspaceStorage";
+import type { Workspace } from "../types";
+import { initFirstWorkspace } from "../workspaceStorage";
-const LOCAL_RELAY_URL = "ws://localhost:3000";
+type WelcomeSetupPage = "welcome" | "create-workspace" | "nostr-key";
+type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
defaultRelayUrl: string;
- onComplete: () => void;
+ initialTransitionMode?: WelcomeTransitionMode;
+ onComplete: (workspace: Workspace) => void;
};
-export function WelcomeSetup({
- defaultRelayUrl,
- onComplete,
-}: WelcomeSetupProps) {
- const isInternalBuild = defaultRelayUrl !== LOCAL_RELAY_URL;
- const [relayUrl, setRelayUrl] = React.useState(defaultRelayUrl);
- const [isConnecting, setIsConnecting] = React.useState(false);
- const [error, setError] = React.useState
(null);
+const DEFAULT_WORKSPACE_HANDOFF_MIN_MS = 200;
+const NOSTR_KEY_FILE_MAX_BYTES = 1024;
- const handleConnect = React.useCallback(async () => {
- const trimmedUrl = relayUrl.trim();
- if (!trimmedUrl) {
- setError("Please enter a relay URL.");
+function wait(ms: number) {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+}
+
+function NostrKeyImportPage({
+ connectionError,
+ disabled,
+ onBack,
+ onImport,
+}: {
+ connectionError: string | null;
+ disabled: boolean;
+ onBack: () => void;
+ onImport: (nsec: string) => Promise;
+}) {
+ const [nsecInput, setNsecInput] = React.useState("");
+ const [isImporting, setIsImporting] = React.useState(false);
+ const [importError, setImportError] = React.useState(null);
+ const [isDragging, setIsDragging] = React.useState(false);
+ const inputRef = React.useRef(null);
+ const fileInputRef = React.useRef(null);
+ const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]);
+ const trimmedInput = nsecInput.trim();
+ const hasInput = trimmedInput.length > 0;
+ const isValid = previewNpub !== null;
+ const isBusy = disabled || isImporting;
+ const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
+ const errorMessage = importError ?? connectionError;
+
+ React.useLayoutEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const openFilePicker = React.useCallback(() => {
+ if (isBusy) {
return;
}
- setIsConnecting(true);
- setError(null);
+ fileInputRef.current?.click();
+ }, [isBusy]);
+
+ const handleFiles = React.useCallback(async (files: FileList | null) => {
+ const file = files?.[0];
+ if (!file) {
+ return;
+ }
+
+ if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
+ setImportError(
+ "That file is too large to be a key. Drop a .key file or paste your nsec.",
+ );
+ return;
+ }
try {
- // We snapshot only the pubkey for display purposes (workspace switcher
- // labels, etc.). The private key lives on disk in `identity.key` and
- // is the single source of truth — never copied into localStorage.
- const identity = await getIdentity();
- initFirstWorkspace(trimmedUrl, identity.pubkey);
-
- // The reload triggered by onComplete() will re-run useWorkspaceInit,
- // which calls applyWorkspace with the saved config. No need to apply here.
- onComplete();
- } catch (err) {
- setError(
- err instanceof Error ? err.message : "Failed to connect. Try again.",
+ const text = await file.text();
+ const firstLine =
+ text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
+ setNsecInput(firstLine.trim());
+ setImportError(null);
+ } catch (error) {
+ setImportError(
+ error instanceof Error ? error.message : "Couldn't read that file.",
);
- setIsConnecting(false);
}
- }, [relayUrl, onComplete]);
+ }, []);
- const workspaceName = React.useMemo(
- () => deriveWorkspaceName(relayUrl.trim() || LOCAL_RELAY_URL),
- [relayUrl],
- );
+ const handleSubmit = React.useCallback(async () => {
+ if (!previewNpub) {
+ setImportError(
+ "That doesn't look like a valid nsec. Paste an nsec1 key.",
+ );
+ return;
+ }
+
+ setIsImporting(true);
+ setImportError(null);
+
+ try {
+ await onImport(trimmedInput);
+ } catch (error) {
+ setImportError(
+ error instanceof Error ? error.message : "Failed to import key.",
+ );
+ } finally {
+ setIsImporting(false);
+ }
+ }, [onImport, previewNpub, trimmedInput]);
return (
-
-
-
- Sprout
-
-
- Welcome
+
+
+
+ Continue using Nostr
-
- {isInternalBuild
- ? "Connect to your workspace to get started."
- : "Running a local relay? Connect now. Or enter a custom relay URL."}
+
+ Import an existing Nostr private key to use that identity with Sprout.
+
-
- {!isInternalBuild ? (
-
-
-
{
- setRelayUrl(e.target.value);
- setError(null);
- }}
- placeholder="ws://localhost:3000"
- type="url"
- value={relayUrl}
- />
+
+
+ );
+}
+
+export function WelcomeSetup({
+ defaultRelayUrl,
+ initialTransitionMode = "initial",
+ onComplete,
+}: WelcomeSetupProps) {
+ const [page, setPage] = React.useState
("welcome");
+ const [transitionMode, setTransitionMode] =
+ React.useState(initialTransitionMode);
+ const [customWorkspaceName, setCustomWorkspaceName] = React.useState("");
+ const [customRelayUrl, setCustomRelayUrl] = React.useState("");
+ const [isConnecting, setIsConnecting] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const systemColorScheme = useSystemColorScheme();
+
+ const handleConnect = React.useCallback(
+ async (relayUrl: string, workspaceName?: string, pubkey?: string) => {
+ const trimmedUrl = relayUrl.trim();
+ if (!trimmedUrl) {
+ setError("Please enter a workspace URL.");
+ return;
+ }
+
+ const handoffStartedAt = performance.now();
+ flushSync(() => {
+ setIsConnecting(true);
+ setError(null);
+ });
+
+ try {
+ // We snapshot only the pubkey for display purposes (workspace switcher
+ // labels, etc.). The private key lives on disk in `identity.key` and
+ // is the single source of truth — never copied into localStorage.
+ const identityPubkey = pubkey ?? (await getIdentity()).pubkey;
+ const workspace = initFirstWorkspace(
+ trimmedUrl,
+ identityPubkey,
+ workspaceName,
+ );
+
+ if (!workspaceName) {
+ const elapsedMs = performance.now() - handoffStartedAt;
+ if (elapsedMs < DEFAULT_WORKSPACE_HANDOFF_MIN_MS) {
+ await wait(DEFAULT_WORKSPACE_HANDOFF_MIN_MS - elapsedMs);
+ }
+ }
+
+ // The parent moves this workspace into React state so first-run setup
+ // can continue without a full page reload.
+ onComplete(workspace);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to connect. Try again.",
+ );
+ setIsConnecting(false);
+ }
+ },
+ [onComplete],
+ );
+
+ const handleNostrImport = React.useCallback(
+ async (nsec: string) => {
+ const identity = await tauriImportIdentity(nsec);
+ await handleConnect(defaultRelayUrl, undefined, identity.pubkey);
+ },
+ [defaultRelayUrl, handleConnect],
+ );
+
+ const handleCustomWorkspaceSubmit = React.useCallback(
+ (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmedName = customWorkspaceName.trim();
+ const trimmedUrl = customRelayUrl.trim();
+ if (!trimmedName) {
+ setError("Please enter a workspace name.");
+ return;
+ }
+ if (!trimmedUrl) {
+ setError("Please enter a workspace URL.");
+ return;
+ }
+ void handleConnect(trimmedUrl, trimmedName);
+ },
+ [customRelayUrl, customWorkspaceName, handleConnect],
+ );
+
+ const showCreateWorkspacePage = React.useCallback(() => {
+ setError(null);
+ setTransitionMode("forward");
+ setPage("create-workspace");
+ }, []);
+
+ const showNostrKeyPage = React.useCallback(() => {
+ setError(null);
+ setTransitionMode("forward");
+ setPage("nostr-key");
+ }, []);
+
+ const showWelcomePage = React.useCallback(() => {
+ setError(null);
+ setTransitionMode("backward");
+ setPage("welcome");
+ }, []);
+
+ const currentStep =
+ page === "welcome" ? (isConnecting ? 2 : 1) : page === "nostr-key" ? 1 : 2;
+ const transitionDirection =
+ transitionMode === "backward" ? "backward" : "forward";
+ const welcomeEffect =
+ transitionMode === "backward" ? "line-slide" : "mask-reveal-up";
+
+ return (
+
+
+
+
+ {page === "welcome" ? (
+
+
+
+
+
+
+ Welcome to Sprout
+
+
+ Choose your first workspace to get started.
+
+
+
+ {
+ if (isConnecting) {
+ return;
+ }
+ setError(null);
+ void handleConnect(defaultRelayUrl);
+ }}
+ type="button"
+ >
+ Continue with Block Inc. workspace
+
+
+ {
+ if (isConnecting) {
+ return;
+ }
+ showCreateWorkspacePage();
+ }}
+ type="button"
+ variant="secondary"
+ >
+ Join a workspace
+
+
+ {
+ if (isConnecting) {
+ return;
+ }
+ showNostrKeyPage();
+ }}
+ type="button"
+ variant="ghost"
+ >
+ Continue using Nostr
+
+
+
+ {error ? (
+
+ ) : null}
+
+ ) : page === "create-workspace" ? (
+
+
+
+ Join a workspace
+
+
+ Workspaces are where teammates and agents collaborate across
+ channels, DMs, and shared projects.
+
+
+
+
+
+ ) : (
+
+ )}
);
diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx
index eae296877a..2380afbda2 100644
--- a/desktop/src/features/workspaces/useWorkspaces.tsx
+++ b/desktop/src/features/workspaces/useWorkspaces.tsx
@@ -10,6 +10,7 @@ import type { ReactNode } from "react";
import type { Workspace } from "./types";
import {
+ clearWorkspaceStorage,
loadActiveWorkspaceId,
loadWorkspaces,
saveActiveWorkspaceId,
@@ -23,6 +24,7 @@ export type UseWorkspacesReturn = {
reinitKey: number;
/** Add a workspace, deduplicating by relayUrl. Returns the final ID in the list. */
addWorkspace: (workspace: Workspace) => string;
+ clearWorkspaces: () => void;
removeWorkspace: (id: string) => void;
switchWorkspace: (id: string) => void;
/** Force the active workspace to re-init (e.g. after a deep-link reconnect). */
@@ -95,6 +97,12 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
return resolvedId;
}, []);
+ const clearWorkspaces = useCallback(() => {
+ clearWorkspaceStorage();
+ setWorkspacesState([]);
+ setActiveId(null);
+ }, []);
+
const removeWorkspace = useCallback(
(id: string) => {
setWorkspacesState((prev) => {
@@ -166,6 +174,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
activeWorkspace,
reinitKey,
addWorkspace,
+ clearWorkspaces,
removeWorkspace,
switchWorkspace,
reconnectWorkspace,
diff --git a/desktop/src/features/workspaces/workspaceStorage.ts b/desktop/src/features/workspaces/workspaceStorage.ts
index 407dd7514e..550ea86156 100644
--- a/desktop/src/features/workspaces/workspaceStorage.ts
+++ b/desktop/src/features/workspaces/workspaceStorage.ts
@@ -41,6 +41,11 @@ export function saveWorkspaces(workspaces: Workspace[]): void {
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(workspaces));
}
+export function clearWorkspaceStorage(): void {
+ localStorage.removeItem(WORKSPACES_KEY);
+ localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
+}
+
export function loadActiveWorkspaceId(): string | null {
return localStorage.getItem(ACTIVE_WORKSPACE_KEY);
}
@@ -83,11 +88,13 @@ export function deriveWorkspaceName(relayUrl: string): string {
export function initFirstWorkspace(
relayUrl: string,
pubkey: string,
+ name?: string,
): Workspace {
const normalizedUrl = normalizeRelayUrl(relayUrl);
+ const trimmedName = name?.trim();
const workspace: Workspace = {
id: crypto.randomUUID(),
- name: deriveWorkspaceName(normalizedUrl),
+ name: trimmedName || deriveWorkspaceName(normalizedUrl),
relayUrl: normalizedUrl,
pubkey,
addedAt: new Date().toISOString(),
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 4aef7294a0..1e2c562fea 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -558,6 +558,14 @@
@apply border-border;
}
+ html,
+ body,
+ #root {
+ height: 100%;
+ min-height: 100%;
+ width: 100%;
+ }
+
mark {
background-color: transparent;
color: inherit;
@@ -596,3 +604,217 @@
cursor: default;
}
}
+
+@layer components {
+ .sprout-startup-shell {
+ min-height: 100dvh;
+ width: 100%;
+ }
+
+ .sprout-onboarding-neutral-theme {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 9%;
+ --sprout-onboarding-emoji-picker-background: 245, 245, 245;
+ --sprout-onboarding-emoji-picker-color: 23, 23, 23;
+ --sprout-onboarding-emoji-picker-input: 255, 255, 255;
+ }
+
+ .dark .sprout-onboarding-neutral-theme {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --sprout-onboarding-emoji-picker-background: 38, 38, 38;
+ --sprout-onboarding-emoji-picker-color: 250, 250, 250;
+ --sprout-onboarding-emoji-picker-input: 10, 10, 10;
+ }
+
+ .sprout-onboarding-neutral-theme[data-system-color-scheme="light"] {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 9%;
+ --sprout-onboarding-emoji-picker-background: 245, 245, 245;
+ --sprout-onboarding-emoji-picker-color: 23, 23, 23;
+ --sprout-onboarding-emoji-picker-input: 255, 255, 255;
+ }
+
+ .sprout-onboarding-neutral-theme[data-system-color-scheme="dark"] {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --sprout-onboarding-emoji-picker-background: 38, 38, 38;
+ --sprout-onboarding-emoji-picker-color: 250, 250, 250;
+ --sprout-onboarding-emoji-picker-input: 10, 10, 10;
+ }
+
+ .sprout-onboarding-transition-content {
+ animation-delay: var(--sprout-onboarding-transition-delay, 0ms);
+ }
+
+ .sprout-onboarding-transition-line[data-onboarding-effect="line-slide"]
+ > .sprout-onboarding-transition-content {
+ animation-duration: 650ms;
+ animation-fill-mode: backwards;
+ animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ .sprout-onboarding-transition-line[data-onboarding-effect="line-slide"][data-onboarding-direction="forward"]
+ > .sprout-onboarding-transition-content {
+ animation-name: sprout-onboarding-line-slide-forward;
+ }
+
+ .sprout-onboarding-transition-line[data-onboarding-effect="line-slide"][data-onboarding-direction="backward"]
+ > .sprout-onboarding-transition-content {
+ animation-name: sprout-onboarding-line-slide-backward;
+ }
+
+ .sprout-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] {
+ overflow: visible;
+ }
+
+ .sprout-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
+ > .sprout-onboarding-transition-content {
+ animation: sprout-onboarding-mask-reveal-up 760ms
+ cubic-bezier(0.22, 1, 0.36, 1) both;
+ animation-delay: var(--sprout-onboarding-transition-delay, 0ms);
+ transform-origin: 50% 70%;
+ }
+
+ .sprout-onboarding-name-placeholder-caret {
+ animation: sprout-onboarding-caret-blink 1.1s steps(1, end) infinite;
+ }
+
+ @keyframes sprout-onboarding-caret-blink {
+ 0%,
+ 55% {
+ opacity: 1;
+ }
+
+ 56%,
+ 100% {
+ opacity: 0;
+ }
+ }
+
+ @keyframes sprout-onboarding-line-slide-forward {
+ from {
+ opacity: 0;
+ transform: translateX(-48px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+
+ @keyframes sprout-onboarding-line-slide-backward {
+ from {
+ opacity: 0;
+ transform: translateX(48px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+
+ @keyframes sprout-onboarding-mask-reveal-up {
+ from {
+ filter: blur(8px);
+ opacity: 0;
+ transform: translate3d(0, 30px, 0) scale(0.985);
+ }
+
+ 56% {
+ filter: blur(2px);
+ opacity: 0.86;
+ }
+
+ to {
+ filter: blur(0);
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+ }
+
+ @media (max-width: 640px) {
+ @keyframes sprout-onboarding-line-slide-forward {
+ from {
+ opacity: 0;
+ transform: translateX(-24px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+
+ @keyframes sprout-onboarding-line-slide-backward {
+ from {
+ opacity: 0;
+ transform: translateX(24px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .sprout-onboarding-transition-content {
+ animation: none;
+ filter: none;
+ opacity: 1;
+ transform: none;
+ }
+
+ .sprout-onboarding-name-placeholder-caret {
+ animation: none;
+ }
+ }
+}
diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx
index 702fff4147..39a3faf729 100644
--- a/desktop/src/shared/theme/ThemeProvider.tsx
+++ b/desktop/src/shared/theme/ThemeProvider.tsx
@@ -15,9 +15,9 @@ import {
loadThemeData,
} from "./theme-loader";
-const STORAGE_KEY = "sprout-theme";
+export const THEME_STORAGE_KEY = "sprout-theme";
const CACHE_KEY = "sprout-theme-cache";
-const ACCENT_KEY = "sprout-accent-color";
+export const ACCENT_STORAGE_KEY = "sprout-accent-color";
export const NEUTRAL_ACCENT = "neutral";
export const ACCENT_COLORS = [
@@ -57,7 +57,7 @@ function isValidThemeName(name: string): name is SyntaxThemeName {
/** Read stored theme, migrating legacy "light"/"dark"/"system" values. */
function readStoredTheme(fallback: SyntaxThemeName): SyntaxThemeName {
- const stored = window.localStorage.getItem(STORAGE_KEY);
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (!stored) return fallback;
// Migrate legacy values
@@ -117,7 +117,8 @@ function applyCachedVars(): string | null {
root.classList.add(isDark ? "dark" : "light");
// Also apply cached accent
- const accent = window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
+ const accent =
+ window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
applyAccentColor(accent);
return themeName;
@@ -172,7 +173,7 @@ export function ThemeProvider({
const [isLoading, setIsLoading] = useState(true);
const loadingRef = useRef(null);
const [accentColor, setAccentColorState] = useState(() => {
- return window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT;
+ return window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT;
});
// Load and apply theme
@@ -191,7 +192,7 @@ export function ThemeProvider({
setIsLoading(false);
// Re-apply accent after theme load (theme vars don't include primary)
applyAccentColor(
- window.localStorage.getItem(ACCENT_KEY) ?? DEFAULT_ACCENT,
+ window.localStorage.getItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT,
);
}
});
@@ -205,11 +206,11 @@ export function ThemeProvider({
const setTheme = useCallback((name: string) => {
if (!isValidThemeName(name)) return;
setThemeName(name);
- window.localStorage.setItem(STORAGE_KEY, name);
+ window.localStorage.setItem(THEME_STORAGE_KEY, name);
}, []);
const setAccentColor = useCallback((color: string) => {
- window.localStorage.setItem(ACCENT_KEY, color);
+ window.localStorage.setItem(ACCENT_STORAGE_KEY, color);
setAccentColorState(color);
}, []);
diff --git a/desktop/src/shared/theme/theme-loader.ts b/desktop/src/shared/theme/theme-loader.ts
index 31e7567c6e..c954d2cc0b 100644
--- a/desktop/src/shared/theme/theme-loader.ts
+++ b/desktop/src/shared/theme/theme-loader.ts
@@ -73,6 +73,16 @@ export const SYNTAX_THEMES = [
export type SyntaxThemeName = (typeof SYNTAX_THEMES)[number];
+const ONBOARDING_THEME_FALLBACK: SyntaxThemeName = "github-light-default";
+const ONBOARDING_THEME_PREFERENCES = [
+ "neutral",
+ ONBOARDING_THEME_FALLBACK,
+] as const;
+
+export const ONBOARDING_DEFAULT_THEME_NAME = (ONBOARDING_THEME_PREFERENCES.find(
+ (name) => (SYNTAX_THEMES as readonly string[]).includes(name),
+) ?? ONBOARDING_THEME_FALLBACK) as SyntaxThemeName;
+
// Known light themes — used by the theme picker to show sun/moon icons
// for themes that haven't been loaded yet.
export const LIGHT_THEMES: ReadonlySet = new Set([
diff --git a/desktop/src/shared/theme/useSystemColorScheme.ts b/desktop/src/shared/theme/useSystemColorScheme.ts
new file mode 100644
index 0000000000..d9ee655128
--- /dev/null
+++ b/desktop/src/shared/theme/useSystemColorScheme.ts
@@ -0,0 +1,37 @@
+import * as React from "react";
+
+export type SystemColorScheme = "dark" | "light";
+
+function getSystemColorScheme(): SystemColorScheme {
+ if (
+ typeof window === "undefined" ||
+ typeof window.matchMedia !== "function"
+ ) {
+ return "light";
+ }
+
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+export function useSystemColorScheme(): SystemColorScheme {
+ const [colorScheme, setColorScheme] =
+ React.useState(getSystemColorScheme);
+
+ React.useEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const updateColorScheme = () => {
+ setColorScheme(mediaQuery.matches ? "dark" : "light");
+ };
+
+ updateColorScheme();
+ mediaQuery.addEventListener("change", updateColorScheme);
+
+ return () => {
+ mediaQuery.removeEventListener("change", updateColorScheme);
+ };
+ }, []);
+
+ return colorScheme;
+}
diff --git a/desktop/src/shared/ui/step-progress.tsx b/desktop/src/shared/ui/step-progress.tsx
new file mode 100644
index 0000000000..39bf4a647b
--- /dev/null
+++ b/desktop/src/shared/ui/step-progress.tsx
@@ -0,0 +1,56 @@
+import { cn } from "@/shared/lib/cn";
+
+type StepProgressProps = {
+ activeSegmentClassName?: string;
+ className?: string;
+ completeSegmentClassName?: string;
+ currentStep: number;
+ inactiveSegmentClassName?: string;
+ totalSteps?: number;
+};
+
+export function StepProgress({
+ activeSegmentClassName,
+ className,
+ completeSegmentClassName,
+ currentStep,
+ inactiveSegmentClassName,
+ totalSteps = 5,
+}: StepProgressProps) {
+ const safeTotalSteps = Math.max(1, totalSteps);
+ const safeCurrentStep = Math.min(Math.max(1, currentStep), safeTotalSteps);
+
+ return (
+
+ {Array.from({ length: safeTotalSteps }, (_, index) => {
+ const step = index + 1;
+ const isActive = step === safeCurrentStep;
+ const isComplete = step < safeCurrentStep;
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 60acfeacda..b1ad96911c 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -1,6 +1,7 @@
-import { hexToBytes } from "@noble/hashes/utils.js";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
-import { finalizeEvent } from "nostr-tools/pure";
+import { decode } from "nostr-tools/nip19";
+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { parse as yamlParse } from "yaml";
import type { RelayEvent } from "@/shared/api/types";
@@ -1621,6 +1622,44 @@ function readStoredIdentityOverride(): TestIdentity | undefined {
}
}
+function writeStoredIdentityOverride(identity: TestIdentity) {
+ window.localStorage.setItem(
+ E2E_IDENTITY_OVERRIDE_STORAGE_KEY,
+ JSON.stringify(identity),
+ );
+}
+
+function importMockIdentity(nsec: string) {
+ const decoded = decode(nsec.trim());
+ if (decoded.type !== "nsec") {
+ throw new Error("Invalid Nostr private key.");
+ }
+
+ const privateKey = bytesToHex(decoded.data);
+ const pubkey = getPublicKey(decoded.data);
+ const username = mockDisplayNames.get(pubkey) ?? "";
+ const identity = {
+ privateKey,
+ pubkey,
+ username,
+ };
+ writeStoredIdentityOverride(identity);
+ if (!mockProfiles.has(pubkey)) {
+ mockProfiles.set(pubkey, {
+ pubkey,
+ display_name: username || null,
+ avatar_url: null,
+ about: null,
+ nip05_handle: null,
+ });
+ }
+
+ return {
+ pubkey,
+ display_name: username,
+ };
+}
+
function isRelayMode(config: E2eConfig | undefined): boolean {
return config?.mode === "relay";
}
@@ -2609,27 +2648,27 @@ async function handleUpdateProfile(
}
const profile = ensureMockProfile(config);
- const nextDisplayName = args.displayName?.trim();
- const nextAvatarUrl = args.avatarUrl?.trim();
- const nextAbout = args.about?.trim();
- const nextNip05Handle = args.nip05Handle?.trim();
+ const hasDisplayNameUpdate = typeof args.displayName === "string";
+ const hasAvatarUrlUpdate = typeof args.avatarUrl === "string";
+ const hasAboutUpdate = typeof args.about === "string";
+ const hasNip05HandleUpdate = typeof args.nip05Handle === "string";
+ const nextDisplayName = args.displayName?.trim() ?? "";
+ const nextAvatarUrl = args.avatarUrl?.trim() ?? "";
+ const nextAbout = args.about?.trim() ?? "";
+ const nextNip05Handle = args.nip05Handle?.trim() ?? "";
- if (nextDisplayName && nextDisplayName !== profile.display_name) {
- profile.display_name = nextDisplayName;
- applyMockDisplayName(profile.pubkey, nextDisplayName);
+ if (hasDisplayNameUpdate && nextDisplayName !== profile.display_name) {
+ profile.display_name = nextDisplayName || null;
+ applyMockDisplayName(profile.pubkey, profile.display_name);
}
- if (nextAvatarUrl && nextAvatarUrl !== profile.avatar_url) {
- profile.avatar_url = nextAvatarUrl;
+ if (hasAvatarUrlUpdate && nextAvatarUrl !== profile.avatar_url) {
+ profile.avatar_url = nextAvatarUrl || null;
}
- if (typeof nextAbout === "string" && nextAbout !== profile.about) {
- profile.about = nextAbout;
+ if (hasAboutUpdate && nextAbout !== profile.about) {
+ profile.about = nextAbout || null;
}
- if (
- typeof nextNip05Handle === "string" &&
- nextNip05Handle !== profile.nip05_handle
- ) {
- profile.nip05_handle =
- nextNip05Handle.length > 0 ? nextNip05Handle : null;
+ if (hasNip05HandleUpdate && nextNip05Handle !== profile.nip05_handle) {
+ profile.nip05_handle = nextNip05Handle || null;
}
return cloneProfile(profile);
@@ -5503,6 +5542,10 @@ export function maybeInstallE2eTauriMocks() {
return DEFAULT_MOCK_IDENTITY;
case "get_nsec":
return "nsec1mock000000000000000000000000000000000000000000000000000000";
+ case "import_identity":
+ return importMockIdentity(
+ (payload as { nsec?: string } | null)?.nsec ?? "",
+ );
case "apply_workspace":
return;
case "get_profile":
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index ada5423e33..b7a09f4c42 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -1,4 +1,6 @@
+import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test, type Page } from "@playwright/test";
+import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -9,10 +11,24 @@ const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
username: "",
};
+const BLANK_AVATAR_PLACEHOLDER_IDENTITY = {
+ ...TEST_IDENTITIES.tyler,
+ pubkey: "1".repeat(64),
+ username: "",
+};
+const BLANK_AVATAR_EMOJI_IDENTITY = {
+ ...TEST_IDENTITIES.tyler,
+ pubkey: "2".repeat(64),
+ username: "",
+};
const FIRST_RUN_ALICE = {
...TEST_IDENTITIES.alice,
username: "",
};
+const FIRST_RUN_KENNY = {
+ ...TEST_IDENTITIES.tyler,
+ username: "Kenny QA",
+};
type TestIdentity = {
privateKey: string;
@@ -55,6 +71,25 @@ async function expectNoHomeSeenEntries(page: Page) {
await expect.poll(async () => readHomeSeenStorageKeys(page)).toEqual([]);
}
+async function selectFirstEmojiFromPicker(page: Page) {
+ const picker = page.locator("em-emoji-picker");
+ await expect(picker).toBeVisible();
+ await expect
+ .poll(() =>
+ picker.evaluate((element) =>
+ Boolean(element.shadowRoot?.querySelector(".scroll button")),
+ ),
+ )
+ .toBe(true);
+ await picker.evaluate((element) => {
+ const button = element.shadowRoot?.querySelector(".scroll button");
+ if (!(button instanceof HTMLElement)) {
+ throw new Error("Emoji picker did not render an emoji button.");
+ }
+ button.click();
+ });
+}
+
async function expectHomeSeenCount(page: Page, expectedCount: number) {
await expect
.poll(async () => {
@@ -83,6 +118,27 @@ async function expectHomeView(page: Page) {
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
}
+async function getMockProfile(page: Page) {
+ return page.evaluate(async () => {
+ const invoke = (
+ window as Window & {
+ __SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
+ command: string,
+ payload?: Record,
+ ) => Promise;
+ }
+ ).__SPROUT_E2E_INVOKE_MOCK_COMMAND__;
+ if (!invoke) {
+ throw new Error("Mock invoke bridge is unavailable.");
+ }
+
+ return (await invoke("get_profile")) as {
+ avatar_url: string | null;
+ display_name: string | null;
+ };
+ });
+}
+
async function expectIncompleteOnboarding(page: Page) {
await expect(page.getByTestId("onboarding-gate")).toBeVisible();
await expectShellHidden(page);
@@ -92,6 +148,19 @@ async function expectIncompleteOnboarding(page: Page) {
async function continueToSetupPage(page: Page) {
await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ await page
+ .getByTestId("onboarding-avatar-url")
+ .fill("https://example.com/onboarding-avatar.png");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
+ await page.getByTestId("onboarding-theme-option-github-light").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-theme")),
+ )
+ .toBe("github-light");
+ await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
}
@@ -108,6 +177,115 @@ test("completed users skip the loading gate while profile is still settling", as
await expectHomeView(page);
});
+test("first-run default workspace handoff gives immediate stepper feedback", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, FIRST_RUN_KENNY);
+ await installMockBridge(
+ page,
+ {
+ profileReadDelayMs: 2_000,
+ },
+ { skipOnboardingSeed: true, skipWorkspaceSeed: true },
+ );
+ await page.goto("/");
+
+ await expect(page.getByText("Welcome to Sprout")).toBeVisible();
+ await page
+ .getByRole("button", { name: "Continue with Block Inc. workspace" })
+ .click();
+
+ await page.waitForTimeout(80);
+ await expect(page.getByRole("button", { name: "Connecting..." })).toHaveCount(
+ 0,
+ );
+ await expect(
+ page.getByRole("button", { name: "Continue with Block Inc. workspace" }),
+ ).toBeVisible();
+ await expect(page.getByRole("progressbar")).toHaveAttribute(
+ "aria-valuenow",
+ "2",
+ );
+ await page.waitForTimeout(240);
+ await expect(page.getByTestId("welcome-continue-nostr")).toBeVisible();
+ await expect(page.getByRole("progressbar")).toHaveAttribute(
+ "aria-valuenow",
+ "2",
+ );
+
+ const nameInput = page.getByTestId("onboarding-display-name");
+ await expect(nameInput).toHaveValue("Kenny QA");
+ await expect(page.getByRole("progressbar")).toHaveAttribute(
+ "aria-valuenow",
+ "2",
+ );
+ await expect(nameInput).toHaveAttribute("autocomplete", "off");
+ await expect(page.getByTestId("onboarding-back")).toBeVisible();
+});
+
+test("welcome can continue using an existing Nostr key", async ({ page }) => {
+ await installMockBridge(page, undefined, {
+ skipOnboardingSeed: true,
+ skipWorkspaceSeed: true,
+ });
+ await page.goto("/");
+
+ await page.getByTestId("welcome-continue-nostr").click();
+ await expect(
+ page.getByRole("heading", { name: "Continue using Nostr" }),
+ ).toBeVisible();
+
+ const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
+ await page.getByTestId("welcome-nostr-nsec-input").fill(importedNsec);
+ await expect(page.getByTestId("welcome-nostr-npub-preview")).toBeVisible();
+ await page.getByTestId("welcome-nostr-submit").click();
+
+ await expect(page.getByTestId("onboarding-display-name")).toHaveValue(
+ "alice",
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(() => {
+ const rawWorkspaces = window.localStorage.getItem("sprout-workspaces");
+ const workspaces = rawWorkspaces
+ ? (JSON.parse(rawWorkspaces) as Array<{ pubkey?: string }>)
+ : [];
+ return workspaces[0]?.pubkey ?? null;
+ }),
+ )
+ .toBe(TEST_IDENTITIES.alice.pubkey);
+ await expect
+ .poll(() =>
+ page.evaluate((storageKey) => {
+ const rawIdentity = window.localStorage.getItem(storageKey);
+ const identity = rawIdentity
+ ? (JSON.parse(rawIdentity) as { pubkey?: string })
+ : null;
+ return identity?.pubkey ?? null;
+ }, E2E_IDENTITY_OVERRIDE_STORAGE_KEY),
+ )
+ .toBe(TEST_IDENTITIES.alice.pubkey);
+});
+
+test("welcome presents custom workspace setup as joining a workspace", async ({
+ page,
+}) => {
+ await installMockBridge(page, undefined, {
+ skipOnboardingSeed: true,
+ skipWorkspaceSeed: true,
+ });
+ await page.goto("/");
+
+ await page.getByRole("button", { name: "Join a workspace" }).click();
+
+ await expect(
+ page.getByRole("heading", { name: "Join a workspace" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Join a workspace" }),
+ ).toBeVisible();
+});
+
test("identity fallback text does not count as a real onboarding name", async ({
page,
}) => {
@@ -115,14 +293,53 @@ test("identity fallback text does not count as a real onboarding name", async ({
await page.goto("/");
await expectIncompleteOnboarding(page);
- await expect(page.getByTestId("onboarding-avatar-upload")).toHaveText(
- "Drop an image or browse",
- );
- await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
});
-test("page 1 accepts an avatar URL as the secondary avatar path", async ({
+test("avatar step uses an add-image placeholder before an avatar is chosen", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_AVATAR_PLACEHOLDER_IDENTITY);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ const preview = page.getByTestId("onboarding-avatar-preview");
+ await expect(preview).toBeVisible();
+ await expect(preview).toHaveAttribute("aria-label", "Add a display image");
+ await expect(preview).toHaveClass(/border-dashed/);
+});
+
+test("avatar step reveals preset backgrounds after the first emoji pick", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_AVATAR_EMOJI_IDENTITY);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+
+ await page.getByRole("tab", { name: "Emoji" }).click();
+
+ const colorGridShell = page.getByTestId("onboarding-avatar-color-grid-shell");
+ await expect(colorGridShell).toHaveAttribute("aria-hidden", "true");
+
+ await selectFirstEmojiFromPicker(page);
+
+ await expect(colorGridShell).toHaveAttribute("aria-hidden", "false");
+ await expect(page.getByTestId("onboarding-avatar-color-grid")).toBeVisible();
+ await expect(page.getByTestId("onboarding-avatar-preview")).not.toHaveCSS(
+ "background-color",
+ "rgb(255, 255, 255)",
+ );
+});
+
+test("avatar step accepts an avatar URL before theme selection", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
@@ -130,6 +347,8 @@ test("page 1 accepts an avatar URL as the secondary avatar path", async ({
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page
.getByTestId("onboarding-avatar-url")
.fill("https://example.com/morty.png");
@@ -137,13 +356,119 @@ test("page 1 accepts an avatar URL as the secondary avatar path", async ({
const preview = page.getByTestId("onboarding-avatar-preview");
await expect(preview).toBeVisible();
const box = await preview.boundingBox();
- expect(box?.width).toBeCloseTo(80, 0);
- expect(box?.height).toBeCloseTo(80, 0);
+ expect(box?.width).toBeCloseTo(192, 0);
+ expect(box?.height).toBeCloseTo(192, 0);
- await continueToSetupPage(page);
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
+ await page.getByTestId("onboarding-theme-option-github-light").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-theme")),
+ )
+ .toBe("github-light");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible();
});
+test("failed avatar saves can continue without saving the avatar", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(page, {}, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ await page
+ .getByTestId("onboarding-avatar-url")
+ .fill("https://example.com/morty.png");
+ await page.evaluate(() => {
+ const testWindow = window as Window & {
+ __SPROUT_E2E__?: { mock?: { profileUpdateError?: string } };
+ };
+ if (testWindow.__SPROUT_E2E__?.mock) {
+ testWindow.__SPROUT_E2E__.mock.profileUpdateError =
+ "Temporary avatar sync failure.";
+ }
+ });
+
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByText("Temporary avatar sync failure.")).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-next-without-saving"),
+ ).toBeVisible();
+ await page.getByTestId("onboarding-next-without-saving").click();
+
+ await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
+});
+
+test("theme step offers skip instead of going back", async ({ page }) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ await page
+ .getByTestId("onboarding-avatar-url")
+ .fill("https://example.com/morty.png");
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-theme")),
+ )
+ .toBe("github-light-default");
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-accent-color")),
+ )
+ .toBe("neutral");
+ await expect
+ .poll(() =>
+ page.evaluate(() => document.documentElement.classList.contains("light")),
+ )
+ .toBe(true);
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-theme")),
+ )
+ .toBe("github-light-default");
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-accent-color")),
+ )
+ .toBe("neutral");
+ await expect(
+ page.getByTestId("onboarding-theme-option-github-light-default"),
+ ).toHaveAttribute("aria-pressed", "true");
+ await expect(
+ page.getByTestId("onboarding-accent-color-neutral"),
+ ).toHaveAttribute("aria-pressed", "true");
+ await page.getByTestId("onboarding-accent-color-blue").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-accent-color")),
+ )
+ .toBe("#3b82f6");
+ await page.getByTestId("onboarding-accent-color-neutral").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("sprout-accent-color")),
+ )
+ .toBe("neutral");
+ await expect(page.getByTestId("onboarding-back")).toHaveCount(0);
+ await page.getByTestId("onboarding-skip").click();
+
+ await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
+});
+
test("avatar upload rejects a file whose server-detected MIME is not an image", async ({
page,
}) => {
@@ -171,13 +496,16 @@ test("avatar upload rejects a file whose server-detected MIME is not an image",
);
await page.goto("/");
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "looks-like.png",
mimeType: "image/png",
buffer: Buffer.from("not really a png"),
});
- await expect(page.getByTestId("onboarding-avatar-error")).toContainText(
+ await expect(page.getByRole("alert")).toContainText(
"Choose a PNG, JPG, GIF, or WebP image.",
);
await expect(page.getByTestId("onboarding-avatar-url")).toHaveValue("");
@@ -205,6 +533,9 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
);
await page.goto("/");
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
await page.getByTestId("onboarding-avatar-input").setInputFiles({
name: "avatar.png",
mimeType: "image/png",
@@ -215,7 +546,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
});
-test("first-run onboarding keeps the shell hidden through both pages and only marks Home seen after finish", async ({
+test("first-run onboarding keeps the shell hidden through setup and only marks Home seen after finish", async ({
page,
}) => {
await seedActiveIdentity(page, FIRST_RUN_ALICE);
@@ -239,15 +570,20 @@ test("first-run onboarding keeps the shell hidden through both pages and only ma
await expectHomeSeenCount(page, 2);
});
-test("existing relay profile auto-skips onboarding without localStorage completion", async ({
+test("existing relay profile prefills the name step without localStorage completion", async ({
page,
}) => {
await seedActiveIdentity(page, TEST_IDENTITIES.alice);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
await page.goto("/");
- await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
- await expectHomeView(page);
+ await expect(page.getByTestId("onboarding-gate")).toBeVisible();
+ await expectShellHidden(page);
+ await expect(page.getByTestId("onboarding-display-name")).toHaveValue(
+ "alice",
+ );
+ await expect(page.getByTestId("onboarding-next")).toBeEnabled();
+ await expect(page.getByTestId("onboarding-back")).toHaveCount(0);
});
test("finishing onboarding auto-joins the #general channel for a new member", async ({
@@ -327,3 +663,89 @@ test("failed first profile saves can be skipped for the current session", async
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
+
+test("failed saved profile saves can continue without retrying the display name", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, TEST_IDENTITIES.alice);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError: "Temporary profile sync failure.",
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await expect(page.getByTestId("onboarding-display-name")).toHaveValue(
+ "alice",
+ );
+ await page.getByTestId("onboarding-display-name").fill("Alice Draft");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByText("Temporary profile sync failure.")).toBeVisible();
+ await page.getByTestId("onboarding-next-without-saving").click();
+
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+ const avatarUrl = "https://example.com/alice-onboarding-avatar.png";
+ await page.getByTestId("onboarding-avatar-url").fill(avatarUrl);
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-page-theme")).toBeVisible();
+ await expect(await getMockProfile(page)).toMatchObject({
+ avatar_url: avatarUrl,
+ display_name: "alice",
+ });
+});
+
+test("membership denial can import a different invited key", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ relayRole: null,
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("membership-denied")).toBeVisible();
+ await page.getByTestId("membership-denied-change-key").click();
+
+ const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
+ await page.getByTestId("membership-denied-nsec-input").fill(importedNsec);
+ await expect(
+ page.getByTestId("membership-denied-npub-preview"),
+ ).toBeVisible();
+ await page.getByTestId("membership-denied-import-key").click();
+
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (window as Window & { __SPROUT_E2E_COMMANDS__?: string[] })
+ .__SPROUT_E2E_COMMANDS__ ?? [],
+ ),
+ )
+ .toEqual(expect.arrayContaining(["plugin:websocket|disconnect"]));
+ await expect(page.getByTestId("onboarding-page-1")).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate((storageKey) => {
+ const rawIdentity = window.localStorage.getItem(storageKey);
+ const identity = rawIdentity
+ ? (JSON.parse(rawIdentity) as { pubkey?: string })
+ : null;
+ return identity?.pubkey ?? null;
+ }, E2E_IDENTITY_OVERRIDE_STORAGE_KEY),
+ )
+ .toBe(TEST_IDENTITIES.alice.pubkey);
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-page-avatar")).toBeVisible();
+});
diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts
index 5b10d3cbc5..fd1a178481 100644
--- a/desktop/tests/e2e/profile.spec.ts
+++ b/desktop/tests/e2e/profile.spec.ts
@@ -445,6 +445,10 @@ test("snaps custom avatar colors to the dot grid", async ({ page }) => {
page.getByTestId("profile-avatar-custom-color-done"),
).toBeVisible();
+ const hueSlider = page.getByTestId("profile-avatar-custom-color-hue");
+ await hueSlider.press("Home");
+ await expect(hueSlider).toHaveAttribute("aria-valuenow", "0");
+
const spectrumBox = await spectrum.boundingBox();
if (!spectrumBox) {
throw new Error("Custom color spectrum did not render bounds.");
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 92aac3ed5a..c0db1039e8 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -98,6 +98,7 @@ type BridgeOptions = {
relayHttpUrl?: string;
relayWsUrl?: string;
skipOnboardingSeed?: boolean;
+ skipWorkspaceSeed?: boolean;
/**
* When true (default), seed every preview feature in preview-features.json as
* enabled in localStorage so E2E tests can interact with gated UI without
@@ -165,9 +166,11 @@ export async function installBridge(page: Page, options: BridgeOptions) {
? TEST_IDENTITIES[options.user ?? "tyler"]
: undefined;
- // Always seed a workspace so useWorkspaceInit doesn't show WelcomeSetup.
+ // Most specs seed a workspace so useWorkspaceInit doesn't show WelcomeSetup.
// skipOnboardingSeed only controls the onboarding-completion flag.
- await seedDefaultWorkspace(page, options.relayWsUrl);
+ if (!options.skipWorkspaceSeed) {
+ await seedDefaultWorkspace(page, options.relayWsUrl);
+ }
if (!options.skipOnboardingSeed) {
await seedOnboardingCompletionForKnownIdentities(page);
}
@@ -264,12 +267,17 @@ export async function installBridge(page: Page, options: BridgeOptions) {
export async function installMockBridge(
page: Page,
mock?: MockBridgeOptions,
- options?: { skipOnboardingSeed?: boolean; seedPreviewFeatures?: boolean },
+ options?: {
+ skipOnboardingSeed?: boolean;
+ skipWorkspaceSeed?: boolean;
+ seedPreviewFeatures?: boolean;
+ },
) {
await installBridge(page, {
mode: "mock",
mock,
skipOnboardingSeed: options?.skipOnboardingSeed,
+ skipWorkspaceSeed: options?.skipWorkspaceSeed,
seedPreviewFeatures: options?.seedPreviewFeatures,
});
}