diff --git a/.github/assets/loading-screen-dark.png b/.github/assets/loading-screen-dark.png new file mode 100644 index 00000000..f15b7fcc Binary files /dev/null and b/.github/assets/loading-screen-dark.png differ diff --git a/.github/assets/loading-screen-light.png b/.github/assets/loading-screen-light.png new file mode 100644 index 00000000..64bb0974 Binary files /dev/null and b/.github/assets/loading-screen-light.png differ diff --git a/apps/web/src/components/AppStartupScreen.tsx b/apps/web/src/components/AppStartupScreen.tsx new file mode 100644 index 00000000..5f2dbfb2 --- /dev/null +++ b/apps/web/src/components/AppStartupScreen.tsx @@ -0,0 +1,45 @@ +import { APP_BASE_NAME, APP_STAGE_LABEL } from "../branding"; +import devLogo from "../../../../assets/dev/blueprint.svg"; +import prodLogo from "../../../../assets/prod/logo.svg"; + +const startupScreenLogo = import.meta.env.DEV ? devLogo : prodLogo; + +export function AppStartupScreen({ + statusMessage = "Starting local backend…", +}: { + readonly statusMessage?: string; +}) { + return ( +
+
+
+
+
+
+ +
+
+
+ {`${APP_BASE_NAME} +
+ +
+
+ + {APP_BASE_NAME} + + + {APP_STAGE_LABEL} + +
+

{statusMessage}

+
+
+
+ ); +} diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts index d656e231..aeb1e400 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { WsConnectionStatus } from "../rpc/wsConnectionState"; -import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface"; +import { + shouldAutoReconnect, + shouldRestartStalledReconnect, + shouldShowProductionStartupLoader, +} from "./WebSocketConnectionSurface"; function makeStatus(overrides: Partial = {}): WsConnectionStatus { return { @@ -110,4 +114,49 @@ describe("WebSocketConnectionSurface.logic", () => { ), ).toBe(false); }); + + it("shows the production startup loader during the initial startup grace window", () => { + expect( + shouldShowProductionStartupLoader({ + hasConnected: false, + startupTimedOut: false, + uiState: "connecting", + }), + ).toBe(!import.meta.env.DEV); + expect( + shouldShowProductionStartupLoader({ + hasConnected: false, + startupTimedOut: false, + uiState: "error", + }), + ).toBe(!import.meta.env.DEV); + expect( + shouldShowProductionStartupLoader({ + hasConnected: false, + startupTimedOut: false, + uiState: "offline", + }), + ).toBe(false); + expect( + shouldShowProductionStartupLoader({ + hasConnected: true, + startupTimedOut: false, + uiState: "connecting", + }), + ).toBe(!import.meta.env.DEV); + expect( + shouldShowProductionStartupLoader({ + hasConnected: true, + startupTimedOut: false, + uiState: "reconnecting", + }), + ).toBe(false); + expect( + shouldShowProductionStartupLoader({ + hasConnected: false, + startupTimedOut: true, + uiState: "error", + }), + ).toBe(false); + }); }); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 8c26d4ea..dd1fe0c3 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -13,11 +13,13 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; +import { AppStartupScreen } from "./AppStartupScreen"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { getWsRpcClient } from "~/wsRpcClient"; const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; +const PRODUCTION_STARTUP_SCREEN_TIMEOUT_MS = 30_000; type WsAutoReconnectTrigger = "focus" | "online"; const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { @@ -195,6 +197,25 @@ function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectio return details.join("\n"); } +export function shouldShowProductionStartupLoader({ + hasConnected, + startupTimedOut, + uiState, +}: { + readonly hasConnected: boolean; + readonly startupTimedOut: boolean; + readonly uiState: WsConnectionUiState; +}): boolean { + // Planned local-model support means some users may intentionally stay offline + // after install, so known offline state should remain explicit immediately. + return ( + !import.meta.env.DEV && + !startupTimedOut && + uiState !== "offline" && + (!hasConnected || uiState === "connecting") + ); +} + function WebSocketBlockingState({ status, uiState, @@ -532,15 +553,36 @@ export function SlowRpcAckToastCoordinator() { export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { const serverConfig = useServerConfig(); const status = useWsConnectionStatus(); + const [startupTimedOut, setStartupTimedOut] = useState(false); + + useEffect(() => { + if (import.meta.env.DEV || serverConfig !== null || startupTimedOut) { + return; + } + + const timeoutId = window.setTimeout(() => { + setStartupTimedOut(true); + }, PRODUCTION_STARTUP_SCREEN_TIMEOUT_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [serverConfig, startupTimedOut]); if (serverConfig === null) { const uiState = getWsConnectionUiState(status); - return ( - - ); + const blockingUiState = uiState === "connected" ? "connecting" : uiState; + if ( + shouldShowProductionStartupLoader({ + hasConnected: status.hasConnected, + startupTimedOut, + uiState: blockingUiState, + }) + ) { + return ; + } + + return ; } return children; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9c664b5b..d6f75ba9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,6 +4,8 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + --animate-startup-emblem: startup-emblem 2.8s ease-in-out infinite; + --animate-startup-halo: startup-halo 2.8s ease-in-out infinite; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); --color-success-foreground: var(--success-foreground); @@ -41,6 +43,28 @@ background-position: -200% 0; } } + @keyframes startup-emblem { + 0%, + 100% { + opacity: 0.84; + transform: scale(0.96); + } + 50% { + opacity: 1; + transform: scale(1); + } + } + @keyframes startup-halo { + 0%, + 100% { + opacity: 0.12; + transform: scale(0.9); + } + 50% { + opacity: 0.3; + transform: scale(1.08); + } + } } @layer base { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8374d239..65ec051c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { resolveUtilityModelSelectionDefault } from "@t3tools/shared/model"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { AppStartupScreen } from "../components/AppStartupScreen"; import { DesktopWindowFrame } from "../components/DesktopWindowFrame"; import { SlowRpcAckToastCoordinator, @@ -72,13 +73,7 @@ function RootRouteView() { if (!readNativeApi()) { return ( -
-
-

- Connecting to {APP_DISPLAY_NAME} server... -

-
-
+
); }