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_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...
-
-
-
+
);
}