From 18903bcecb466a77fb223402146d003fbcb669da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 26 Apr 2026 11:28:52 +0700 Subject: [PATCH] refactor: simplify startup splash screen to logo with shimmer Replace text-heavy splash ("Welcome to Paseo", "Starting local server...", "Connecting...") with just the logo centered with a shimmer sweep effect. Error state preserved as-is. --- .../app/src/screens/startup-splash-screen.tsx | 270 ++++++++++++------ 1 file changed, 176 insertions(+), 94 deletions(-) diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 1d0f14bdd..5c8b7ba9e 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -1,15 +1,25 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, ScrollView, Text, View } from "react-native"; +import { ScrollView, Text, View } from "react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; +import MaskedView from "@react-native-masked-view/masked-view"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import * as Clipboard from "expo-clipboard"; import { openExternalUrl } from "@/utils/open-external-url"; -import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; +import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { Button } from "@/components/ui/button"; import { Fonts } from "@/constants/theme"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; interface StartupSplashScreenProps { @@ -23,6 +33,10 @@ interface StartupSplashScreenProps { const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; const DOCS_URL = "https://paseo.sh/docs"; +const LOGO_SIZE = 96; +const SHIMMER_PEAK_WIDTH = 120; +const SHIMMER_DURATION_MS = 1800; + function openGithubIssue(): void { void openExternalUrl(GITHUB_ISSUE_URL); } @@ -31,6 +45,141 @@ function openDocs(): void { void openExternalUrl(DOCS_URL); } +const WEB_SPLASH_SHIMMER_KEYFRAME_ID = "paseo-splash-shimmer-keyframes"; +const WEB_SPLASH_SHIMMER_ANIMATION_NAME = "paseo-splash-shimmer"; + +const WEB_SPLASH_SHIMMER_KEYFRAME_CSS = ` + @keyframes ${WEB_SPLASH_SHIMMER_ANIMATION_NAME} { + 0% { + background-position: -${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0; + } + 100% { + background-position: ${LOGO_SIZE + SHIMMER_PEAK_WIDTH}px 0; + } + } +`; + +let webSplashShimmerRegistered = false; + +function ensureWebSplashShimmerKeyframes() { + if (isNative) { + return; + } + if (webSplashShimmerRegistered) { + return; + } + const existing = document.getElementById(WEB_SPLASH_SHIMMER_KEYFRAME_ID); + if (existing) { + webSplashShimmerRegistered = true; + return; + } + const styleElement = document.createElement("style"); + styleElement.id = WEB_SPLASH_SHIMMER_KEYFRAME_ID; + styleElement.textContent = WEB_SPLASH_SHIMMER_KEYFRAME_CSS; + document.head.appendChild(styleElement); + webSplashShimmerRegistered = true; +} + +function LogoShimmer() { + const { theme } = useUnistyles(); + + if (isWeb) { + return ; + } + + return ; +} + +function WebLogoShimmer({ color }: { color: string }) { + useEffect(() => { + ensureWebSplashShimmerKeyframes(); + }, []); + + const shimmerStyle = useMemo( + () => ({ + width: LOGO_SIZE, + height: LOGO_SIZE, + WebkitMaskImage: `url("data:image/svg+xml,${encodeURIComponent(``)}")`, + WebkitMaskSize: "contain", + WebkitMaskRepeat: "no-repeat", + WebkitMaskPosition: "center", + background: `linear-gradient(90deg, ${color} 0%, ${color}88 40%, ${color}FF 50%, ${color}88 60%, ${color} 100%)`, + backgroundSize: `${LOGO_SIZE + SHIMMER_PEAK_WIDTH * 2}px ${LOGO_SIZE}px`, + animationName: WEB_SPLASH_SHIMMER_ANIMATION_NAME, + animationDuration: `${SHIMMER_DURATION_MS}ms`, + animationTimingFunction: "linear", + animationIterationCount: "infinite", + }), + [color], + ); + + return ; +} + +function NativeLogoShimmer({ color }: { color: string }) { + const shimmerTranslateX = useSharedValue(-SHIMMER_PEAK_WIDTH); + + useEffect(() => { + shimmerTranslateX.value = -SHIMMER_PEAK_WIDTH; + shimmerTranslateX.value = withRepeat( + withTiming(LOGO_SIZE + SHIMMER_PEAK_WIDTH, { + duration: SHIMMER_DURATION_MS, + easing: Easing.linear, + }), + -1, + false, + ); + return () => { + cancelAnimation(shimmerTranslateX); + }; + }, [shimmerTranslateX]); + + const peakStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: shimmerTranslateX.value }], + })); + + const trackStyle = useMemo( + () => [styles.nativeShimmerTrack, { width: LOGO_SIZE, height: LOGO_SIZE }], + [], + ); + + const peakCombinedStyle = useMemo( + () => [styles.nativeShimmerPeak, peakStyle, { width: SHIMMER_PEAK_WIDTH, height: LOGO_SIZE }], + [peakStyle], + ); + + const maskElement = useMemo( + () => ( + + + + ), + [], + ); + + return ( + + + + + + + + + + + + + + + + + + + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { position: "relative", @@ -41,10 +190,6 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[8], paddingVertical: theme.spacing[8], }, - containerError: { - justifyContent: "flex-start", - paddingTop: theme.spacing[16], - }, errorScreen: { position: "relative", flex: 1, @@ -68,12 +213,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[8], paddingTop: theme.spacing[16], }, - centeredContent: { - alignItems: "center", - justifyContent: "center", - maxWidth: 520, - width: "100%", - }, errorContent: { alignItems: "stretch", maxWidth: 720, @@ -84,43 +223,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "flex-start", }, title: { - marginTop: theme.spacing[8], color: theme.colors.foreground, fontSize: theme.fontSize["3xl"], fontWeight: theme.fontWeight.semibold, - textAlign: "center", - }, - titleError: { textAlign: "left", }, - subtitleRow: { - marginTop: theme.spacing[4], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - progressSteps: { - marginTop: theme.spacing[4], - gap: theme.spacing[3], - width: "100%", - }, - progressStepRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[3], - }, - subtitle: { - marginTop: theme.spacing[8], - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.lg, - textAlign: "center", - }, - subtitleInline: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.lg, - textAlign: "center", - }, errorDescription: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, @@ -167,10 +274,29 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[3], flexWrap: "wrap", }, + shimmerMask: { + width: LOGO_SIZE, + height: LOGO_SIZE, + alignItems: "center", + justifyContent: "center", + }, + nativeShimmerTrack: { + overflow: "hidden", + }, + nativeShimmerBase: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + nativeShimmerPeak: { + position: "absolute", + top: 0, + bottom: 0, + }, })); -const TITLE_ERROR_STYLE = [styles.title, styles.titleError]; - export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { theme } = useUnistyles(); const webScrollbarStyle = useWebScrollbarStyle(); @@ -186,9 +312,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); - const phase = bootstrapState?.phase; - const isError = phase === "error"; - const isSimpleSplash = bootstrapState === undefined; + const isError = bootstrapState?.phase === "error"; useEffect(() => { if (!isError) { @@ -229,23 +353,6 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps }; }, [isError]); - let progressSteps: { key: string; label: string; status: "active" | "complete" }[]; - if (phase === "starting-daemon") { - progressSteps = [ - { key: "starting-daemon", label: "Starting local server...", status: "active" }, - ]; - } else if (phase === "connecting") { - progressSteps = [ - { key: "starting-daemon", label: "Started local server", status: "complete" }, - { key: "connecting", label: "Connecting to local server...", status: "active" }, - ]; - } else { - progressSteps = [ - { key: "starting-daemon", label: "Started local server", status: "complete" }, - { key: "connecting", label: "Connected to local server", status: "complete" }, - ]; - } - const logsText = useMemo(() => { if (isLoadingLogs) { return "Loading daemon logs..."; @@ -283,36 +390,11 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps [theme.colors.palette.white], ); - if (isSimpleSplash) { - return ( - - - - Connecting… - - ); - } - if (!isError) { return ( - - - Welcome to Paseo - - {progressSteps.map((step) => ( - - {step.status === "complete" ? ( - - ) : ( - - )} - {step.label} - - ))} - - + ); } @@ -328,7 +410,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps - Something went wrong + Something went wrong