Skip to content
Merged
42 changes: 36 additions & 6 deletions desktop/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,48 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<!--
Boot background: the document must be black from its very first paint,
before the app bundle (and its stylesheets) load, so cold boot never
flashes white ahead of the loading gate. The window itself is also
black pre-document via `backgroundColor` in tauri.conf.json. The body
paints its own themed background once the app CSS loads, so this never
shows through after boot.
Boot background: the document must be painted before the app bundle (and
its stylesheets) load so cold boot never flashes ahead of the loading
gate. The window itself is also painted pre-document via `backgroundColor`
in tauri.conf.json. The body paints its own themed background once the app
CSS loads, so this never shows through after boot.

The inline script below reads the cached theme background (same
`buzz-theme-cache` entry ThemeProvider writes) and applies it synchronously
so the boot color matches the themed loading gate — no black flash on light
themes. On the first-ever launch (no cache yet) it seeds the `dark` class
synchronously so the setup gate reads the dark `:root` vars instead of the
light Catppuccin-Latte default, matching the dark `houston` theme
ThemeProvider applies moments later — no light flash before it loads.
-->
<style>
html {
background-color: #000;
}
</style>
<script>
(() => {
var cached, bg, parsed;
try {
cached = window.localStorage.getItem("buzz-theme-cache");
if (!cached) {
// No stored theme: the default theme (`houston`) is dark, so seed
// the `dark` class now. Otherwise the setup gate would paint from
// the light `:root` vars until ThemeProvider loads asynchronously.
document.documentElement.classList.add("dark");
return;
}
parsed = JSON.parse(cached);
bg = parsed.vars["--background"];
if (bg) document.documentElement.style.backgroundColor = `hsl(${bg})`;
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply cached CSS vars before painting the splash

When the cached theme is anything other than the built-in Catppuccin light/dark vars, this only copies --background into html.style.backgroundColor and then adds a light/dark class. Fresh evidence beyond the prior thread is that ThemeProvider.applyCachedVars() reapplies the entire cached vars map (desktop/src/shared/theme/ThemeProvider.tsx:224-228), while body and the new loading gate read var(--background), --chart-*, etc. from CSS variables before React mounts; with a cached Houston/Buzz/custom theme the pre-React paint still uses the static Catppuccin variables and can flash the wrong themed splash until the bundle runs. Seed the cached vars on documentElement.style here before adding the class.

Useful? React with 👍 / 👎.

// Match the cached light/dark class so themed vars resolve correctly
// before ThemeProvider mounts.
document.documentElement.classList.add(parsed.isDark ? "dark" : "light");
} catch {
/* fall back to the black default above */
}
})();
</script>
</head>

<body>
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default defineConfig({
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/workspace-rail.spec.ts",
"**/boot-splash.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
"**/animated-avatar.spec.ts",
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"width": 800,
"height": 600,
"maximized": true,
"visible": false,
"visible": true,
"backgroundColor": "#000000",
"titleBarStyle": "Overlay",
"hiddenTitle": true,
Expand Down
123 changes: 116 additions & 7 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "react";

import { router } from "@/app/router";
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
import { useReloadShortcut } from "@/app/useReloadShortcut";
import { useAppOnboardingState } from "@/features/onboarding/hooks";
import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition";
Expand All @@ -25,27 +26,108 @@ import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { listenForDeepLinks } from "@/shared/deep-link";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import { Spinner } from "@/shared/ui/spinner";
import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";

const LOADING_TEXT = "Setting up your workspace...";

// Cold boot gate: a plain static Buzz mark (#D7D72E) on solid black. No
// animation machinery, no gradient — the mark must paint complete on the very
// first frame, even in webviews that render before scripting or SMIL start.
// Minimum time the cold-boot splash stays on screen. A real boot resolves the
// workspace in well under 100ms, and the hidden Tauri window (visible:false
// until getCurrentWindow().show()) takes longer than that to put its first
// frame on screen — without a hold, the bee is unmounted before it is ever
// visible. The hold runs as an overlay above the already-mounted app, so
// time-to-interactive is unchanged; only the reveal waits.
const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200;
const BOOT_SPLASH_FADE_MS = 200;

type BootSplashPhase = "holding" | "fading" | "done";

// E2E runs skip the hold (it would slow every spec's boot and block pointer
// actionability); a spec can opt back in via __BUZZ_E2E__.bootSplashHoldMs.
function bootSplashHoldMs(): number {
const e2e = (
window as Window & {
__BUZZ_E2E__?: { bootSplashHoldMs?: number };
}
).__BUZZ_E2E__;
if (e2e) {
return e2e.bootSplashHoldMs ?? 0;
}
return BOOT_SPLASH_MIN_VISIBLE_MS;
}

function useBootSplashHold(): BootSplashPhase {
const [phase, setPhase] = useState<BootSplashPhase>(() =>
bootSplashHoldMs() > 0 ? "holding" : "done",
);

useEffect(() => {
const holdMs = bootSplashHoldMs();
if (holdMs <= 0) {
return;
}
const fadeTimer = window.setTimeout(() => setPhase("fading"), holdMs);
const doneTimer = window.setTimeout(
() => setPhase("done"),
holdMs + BOOT_SPLASH_FADE_MS,
);
return () => {
window.clearTimeout(fadeTimer);
window.clearTimeout(doneTimer);
};
}, []);

return phase;
}

// Animated Buzz mark for the loading gates. The static BuzzMark renders in
// normal flow and sizes the box — it's plain SVG (no JS/SMIL), so it paints on
// the very first frame even before scripting starts, avoiding a blank flash on
// hard reload. The animated FuzzyLogo is layered on top and takes over once it
// begins playing.
function BeeLoader({
ariaLabel,
className,
tintClassName = "text-foreground",
}: {
ariaLabel: string;
className?: string;
tintClassName?: string;
}) {
return (
<div className={cn("relative", tintClassName, className)}>
<BuzzMark className="block h-auto w-full" />
<FuzzyLogo
ariaLabel={ariaLabel}
className="absolute inset-0 h-full! w-full! [&>svg]:h-full [&>svg]:w-full [&>svg]:max-w-full"
fuzz
loop
loopRestSeconds={0}
/>
</div>
);
}

// Cold boot gate: the theme-adaptive grainient background with a single
// centered Buzz bee flying over it — the same static mark as before, now with
// its wings flapping (ported from the Buzz website's wing-flap). Replaces the
// old "Setting up your workspace" text, which stays as an sr-only caption.
function AppLoadingGate() {
return (
<div
className="flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-black px-6 py-10 text-[#d7d72e]"
className="buzz-setup-loading-shell flex min-h-dvh flex-col items-center justify-center overflow-hidden px-6 py-10"
data-testid="app-loading-gate"
role="status"
>
<StartupWindowDragRegion />
<ThemeGrainientBackground />
<span className="sr-only">{LOADING_TEXT}</span>
<BuzzMark className="h-auto w-28" />
<FlappingBee className="relative z-10 h-auto w-28" />
</div>
);
}
Expand All @@ -69,7 +151,11 @@ function WorkspaceSwitchGate() {
<StartupWindowDragRegion />
<span className="sr-only">Switching workspace…</span>
{showSpinner ? (
<Spinner aria-hidden="true" className="text-muted-foreground" />
<BeeLoader
ariaLabel="Switching workspace…"
className="h-auto w-20"
tintClassName="text-muted-foreground"
/>
) : null}
</div>
);
Expand Down Expand Up @@ -313,6 +399,8 @@ export function App() {
setIsCompletingFirstRunWorkspace(false);
}, []);

const bootSplashPhase = useBootSplashHold();

// Wait for the shared-identity IPC call to resolve before rendering
// anything that depends on it. Without this gate, children briefly see
// isSharedIdentity=false and may flash WelcomeSetup or the onboarding flow.
Expand Down Expand Up @@ -343,6 +431,14 @@ export function App() {
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
}

// The app mounts (and starts loading data) beneath the splash overlay; the
// overlay just keeps the bee on screen long enough to be seen, then fades.
// Workspace switches and first-run completion keep their quiet gates.
const showBootSplashOverlay =
bootSplashPhase !== "done" &&
!isWorkspaceSwitch &&
!isCompletingFirstRunWorkspace;

return (
<WorkspaceQueryProvider key={workspaceKey}>
<AppReady
Expand All @@ -354,6 +450,19 @@ export function App() {
onFirstRunWorkspaceSettled={handleFirstRunWorkspaceSettled}
onBackToWorkspaceSetup={handleBackToWorkspaceSetup}
/>
{showBootSplashOverlay ? (
<div
aria-hidden="true"
className={cn(
"fixed inset-0 z-50 transition-opacity",
bootSplashPhase === "fading" ? "opacity-0" : "opacity-100",
)}
data-testid="boot-splash-overlay"
style={{ transitionDuration: `${BOOT_SPLASH_FADE_MS}ms` }}
>
<AppLoadingGate />
</div>
) : null}
</WorkspaceQueryProvider>
);
}
12 changes: 12 additions & 0 deletions desktop/src/app/ThemeGrainientBackground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function ThemeGrainientBackground() {
return (
<div
aria-hidden="true"
className="buzz-setup-grainient"
data-testid="setup-grainient-background"
>
<div className="buzz-setup-grainient__wash" />
<div className="buzz-setup-grainient__veil" />
</div>
);
}
Loading
Loading