feat(desktop): flapping bee on the setup loading screen (#1631)

Signed-off-by: Fizz <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: klopez4212 <klopez4212@gmail.com>
Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-08 10:25:03 -07:00
committed by GitHub
co-authored by npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 Claude Fable 5
parent 3e982fd371
commit c145037aad
9 changed files with 651 additions and 34 deletions
+36 -6
View File
@@ -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})`;
// 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>
+1
View File
@@ -61,6 +61,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",
+1 -1
View File
@@ -20,7 +20,7 @@
"width": 800,
"height": 600,
"maximized": true,
"visible": false,
"visible": true,
"backgroundColor": "#000000",
"titleBarStyle": "Overlay",
"hiddenTitle": true,
+116 -7
View File
@@ -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";
@@ -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>
);
}
@@ -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>
);
@@ -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.
@@ -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
@@ -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>
);
}
@@ -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>
);
}
@@ -567,3 +567,280 @@
}
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
/* ── Setup / cold-boot loading screen ───────────────────────────────────
Theme-adaptive grainient background for the "setting up your workspace"
gate. Restored from pre-#1570; the animated Buzz mark is the hero (see
AppLoadingGate in app/App.tsx). */
@property --buzz-grainient-x-0 {
syntax: "<percentage>";
inherits: false;
initial-value: 85%;
}
@property --buzz-grainient-y-0 {
syntax: "<percentage>";
inherits: false;
initial-value: 80%;
}
@property --buzz-grainient-x-1 {
syntax: "<percentage>";
inherits: false;
initial-value: 60%;
}
@property --buzz-grainient-y-1 {
syntax: "<percentage>";
inherits: false;
initial-value: 24%;
}
@property --buzz-grainient-x-2 {
syntax: "<percentage>";
inherits: false;
initial-value: 13%;
}
@property --buzz-grainient-y-2 {
syntax: "<percentage>";
inherits: false;
initial-value: 82%;
}
@property --buzz-grainient-x-3 {
syntax: "<percentage>";
inherits: false;
initial-value: 24%;
}
@property --buzz-grainient-y-3 {
syntax: "<percentage>";
inherits: false;
initial-value: 7%;
}
.buzz-setup-loading-shell {
--buzz-grainient-alpha-strong: 0.46;
--buzz-grainient-alpha-medium: 0.34;
--buzz-grainient-alpha-soft: 0.24;
--buzz-grainient-color-0: hsl(
var(--chart-5) /
var(--buzz-grainient-alpha-strong)
);
--buzz-grainient-color-1: hsl(
var(--chart-3) /
var(--buzz-grainient-alpha-strong)
);
--buzz-grainient-color-2: hsl(
var(--primary) /
var(--buzz-grainient-alpha-medium)
);
--buzz-grainient-color-3: hsl(
var(--chart-2) /
var(--buzz-grainient-alpha-strong)
);
background-color: hsl(var(--background));
color: hsl(var(--foreground));
isolation: isolate;
position: relative;
}
.dark .buzz-setup-loading-shell {
--buzz-grainient-alpha-strong: 0.68;
--buzz-grainient-alpha-medium: 0.5;
--buzz-grainient-alpha-soft: 0.34;
}
.buzz-setup-grainient {
inset: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
z-index: 0;
}
.buzz-setup-grainient__wash,
.buzz-setup-grainient__veil {
inset: 0;
position: absolute;
}
.buzz-setup-grainient__wash {
--buzz-grainient-s-start-0: 9%;
--buzz-grainient-s-end-0: 55%;
--buzz-grainient-s-start-1: 5%;
--buzz-grainient-s-end-1: 72%;
--buzz-grainient-s-start-2: 5%;
--buzz-grainient-s-end-2: 52%;
--buzz-grainient-s-start-3: 13%;
--buzz-grainient-s-end-3: 68%;
--buzz-grainient-x-0: 85%;
--buzz-grainient-y-0: 80%;
--buzz-grainient-x-1: 60%;
--buzz-grainient-y-1: 24%;
--buzz-grainient-x-2: 13%;
--buzz-grainient-y-2: 82%;
--buzz-grainient-x-3: 24%;
--buzz-grainient-y-3: 7%;
animation: buzz-grainient-orbit 10s linear infinite alternate;
background-color: hsl(var(--background));
background-image:
radial-gradient(
circle at var(--buzz-grainient-x-0) var(--buzz-grainient-y-0),
var(--buzz-grainient-color-0) var(--buzz-grainient-s-start-0),
transparent var(--buzz-grainient-s-end-0)
),
radial-gradient(
circle at var(--buzz-grainient-x-1) var(--buzz-grainient-y-1),
var(--buzz-grainient-color-1) var(--buzz-grainient-s-start-1),
transparent var(--buzz-grainient-s-end-1)
),
radial-gradient(
circle at var(--buzz-grainient-x-2) var(--buzz-grainient-y-2),
var(--buzz-grainient-color-2) var(--buzz-grainient-s-start-2),
transparent var(--buzz-grainient-s-end-2)
),
radial-gradient(
circle at var(--buzz-grainient-x-3) var(--buzz-grainient-y-3),
var(--buzz-grainient-color-3) var(--buzz-grainient-s-start-3),
transparent var(--buzz-grainient-s-end-3)
);
background-blend-mode: normal, normal, normal, normal;
contain: paint;
filter: saturate(1.08);
transform: translateZ(0);
will-change: transform, opacity;
}
.buzz-setup-grainient__veil {
background:
radial-gradient(
ellipse 74% 54% at 50% 52%,
transparent 0%,
hsl(var(--background) / 0.04) 72%,
hsl(var(--background) / 0.16) 100%
),
linear-gradient(
180deg,
hsl(var(--background) / 0.04),
hsl(var(--background) / 0.12)
);
}
@keyframes buzz-grainient-orbit {
0% {
--buzz-grainient-x-0: 85%;
--buzz-grainient-y-0: 80%;
--buzz-grainient-x-1: 60%;
--buzz-grainient-y-1: 24%;
--buzz-grainient-x-2: 13%;
--buzz-grainient-y-2: 82%;
--buzz-grainient-x-3: 24%;
--buzz-grainient-y-3: 7%;
}
100% {
--buzz-grainient-x-0: 31%;
--buzz-grainient-y-0: 94%;
--buzz-grainient-x-1: 2%;
--buzz-grainient-y-1: 25%;
--buzz-grainient-x-2: 98%;
--buzz-grainient-y-2: 20%;
--buzz-grainient-x-3: 95%;
--buzz-grainient-y-3: 92%;
}
}
@media (prefers-reduced-motion: reduce) {
.buzz-setup-grainient__wash {
animation: none;
}
}
/* Buzz bee wing-flap (ported from the Buzz website's BeeField, tuned for a
single hero bee). The two wing lobes beat continuously — a fast in/out scale
so the bee reads as actively flapping on the loading gate rather than resting
most of the cycle (the website staggers many bees, so it could afford long
rests; one bee cannot).
The animated elements are HTML-level <svg> wings (see FlappingBee.tsx), not
SVG children: WebKit paints SVG children on the main thread, so their
animations freeze while boot work hogs the thread — exactly when the loading
gate is visible. HTML-level transforms run on the compositor and keep
flapping. Translation is in percentages of the wing's own 183.4-unit box
(30/183.4 = 16.3577%) so the beat is identical at any rendered size.
The loading gate can be short-lived on a warm refresh, so the flap starts
mid-stroke (negative animation-delay, quarter beat in) — the bee is never
caught frozen at the rest pose on first paint, and a one-frame flash shows
wings visibly mid-beat rather than at the squished full-tuck extreme. The
optional --flap-delay custom property can still stagger multiple bees. */
.bee-sprite .bee-wing {
animation-delay: var(--flap-delay, -0.07s);
animation-duration: 0.28s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
will-change: transform;
}
.bee-sprite .bee-wing-left {
animation-name: bee-wing-left-flap;
}
.bee-sprite .bee-wing-right {
animation-name: bee-wing-right-flap;
}
/* Static cutout masks over the wing layers. In the single-SVG mark, the slot
cutouts punch through body AND wings (the wings tuck behind the body far
enough to sit under the slots' left ends). The wing layers reproduce those
holes with a static CSS mask on the non-animated wrapper, so the holes stay
fixed while the wing beats beneath them — matching the masked single-SVG
rendering. Rect coordinates are the mark's slot cutouts translated into each
wing's local 183.4x183.4 box (left wing box starts at x=0, y=62.8; right
wing box at x=282.6, y=62.8). The eye cutouts never intersect the wing
circles, so they are omitted. */
.bee-wing-layer {
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.bee-wing-layer-left {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 183.4 183.4'%3E%3Cpath fill-rule='evenodd' d='M0,0H183.4V183.4H0ZM171.3,94.4H298.2A5,5 0 0 1 303.2,99.4V127.7A5,5 0 0 1 298.2,132.7H171.3A5,5 0 0 1 166.3,127.7V99.4A5,5 0 0 1 171.3,94.4ZM171.9,172.3H298.1A5,5 0 0 1 303.1,177.3V204.9A5,5 0 0 1 298.1,209.9H171.9A5,5 0 0 1 166.9,204.9V177.3A5,5 0 0 1 171.9,172.3Z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 183.4 183.4'%3E%3Cpath fill-rule='evenodd' d='M0,0H183.4V183.4H0ZM171.3,94.4H298.2A5,5 0 0 1 303.2,99.4V127.7A5,5 0 0 1 298.2,132.7H171.3A5,5 0 0 1 166.3,127.7V99.4A5,5 0 0 1 171.3,94.4ZM171.9,172.3H298.1A5,5 0 0 1 303.1,177.3V204.9A5,5 0 0 1 298.1,209.9H171.9A5,5 0 0 1 166.9,204.9V177.3A5,5 0 0 1 171.9,172.3Z'/%3E%3C/svg%3E");
}
.bee-wing-layer-right {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 183.4 183.4'%3E%3Cpath fill-rule='evenodd' d='M0,0H183.4V183.4H0ZM-111.3,94.4H15.6A5,5 0 0 1 20.6,99.4V127.7A5,5 0 0 1 15.6,132.7H-111.3A5,5 0 0 1 -116.3,127.7V99.4A5,5 0 0 1 -111.3,94.4ZM-110.7,172.3H15.5A5,5 0 0 1 20.5,177.3V204.9A5,5 0 0 1 15.5,209.9H-110.7A5,5 0 0 1 -115.7,204.9V177.3A5,5 0 0 1 -110.7,172.3Z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 183.4 183.4'%3E%3Cpath fill-rule='evenodd' d='M0,0H183.4V183.4H0ZM-111.3,94.4H15.6A5,5 0 0 1 20.6,99.4V127.7A5,5 0 0 1 15.6,132.7H-111.3A5,5 0 0 1 -116.3,127.7V99.4A5,5 0 0 1 -111.3,94.4ZM-110.7,172.3H15.5A5,5 0 0 1 20.5,177.3V204.9A5,5 0 0 1 15.5,209.9H-110.7A5,5 0 0 1 -115.7,204.9V177.3A5,5 0 0 1 -110.7,172.3Z'/%3E%3C/svg%3E");
}
@keyframes bee-wing-left-flap {
0%,
100% {
transform: translateX(0) scaleX(1);
}
50% {
transform: translateX(16.3577%) scaleX(0.62);
}
}
@keyframes bee-wing-right-flap {
0%,
100% {
transform: translateX(0) scaleX(1);
}
50% {
transform: translateX(-16.3577%) scaleX(0.62);
}
}
@media (prefers-reduced-motion: reduce) {
.bee-sprite .bee-wing {
animation: none;
}
}
@@ -0,0 +1,117 @@
import { useId } from "react";
/**
* The Buzz bee mark with flapping wings. Geometry is identical to the static
* {@link BuzzMark} (v8 final keyframe) — the same silhouette, rendered in
* `currentColor` so it tints per-theme — with the wing-flap keyframes (ported
* from the Buzz website) beating the wings on an infinite loop.
*
* Unlike the static mark's single `<svg>`, each wing here is its own
* HTML-level `<svg>` layer and the flap animates those elements' CSS
* transforms. This is deliberate: WebKit paints SVG *children* on the main
* thread, so a transform animation on a `<circle>` freezes for as long as boot
* work (bundle eval, first React render of the app tree) hogs the thread —
* exactly the window in which the loading gate is on screen. Transforms on
* HTML-level elements run on the compositor (Core Animation in WKWebView) and
* keep flapping regardless. The `bee-wing-layer` masks reproduce the slot
* cutouts over the wings so the layered build stays pixel-identical to the
* masked single-SVG mark (see animations.css).
*
* Everything is plain SVG + CSS (no JS/SMIL), so it paints on the very first
* frame and the flap starts as soon as styles load. Reduced motion falls back
* to the static silhouette via the CSS media query.
*/
export function FlappingBee({ className }: { className?: string }) {
const maskId = `flapping-bee-cutouts-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
// Wing geometry from the 466x309 mark: circles r=91.7 at (91.7, 154.5) and
// (374.3, 154.5). Each wing layer is the circle's bounding box, positioned
// as percentages of the mark: top 62.8/309, size 183.4/466 x 183.4/309.
const wingLayer =
"bee-wing-layer absolute top-[20.3236%] h-[59.3528%] w-[39.3562%]";
const wingSvg = "bee-wing block h-full w-full";
return (
<div
aria-hidden="true"
className={[
"buzz-mark",
"bee-sprite",
"relative",
"aspect-[466/309]",
className,
]
.filter(Boolean)
.join(" ")}
>
<div className={`${wingLayer} bee-wing-layer-left left-0`}>
<svg
aria-hidden="true"
className={`${wingSvg} bee-wing-left`}
viewBox="0 0 183.4 183.4"
fill="currentColor"
>
<circle cx="91.7" cy="91.7" r="91.7" />
</svg>
</div>
<div className={`${wingLayer} bee-wing-layer-right right-0`}>
<svg
aria-hidden="true"
className={`${wingSvg} bee-wing-right`}
viewBox="0 0 183.4 183.4"
fill="currentColor"
>
<circle cx="91.7" cy="91.7" r="91.7" />
</svg>
</div>
{/* Body last in DOM order and positioned, so it paints over the wings —
matching the single-SVG mark where the body rect draws on top. */}
<svg
aria-hidden="true"
className="relative block h-full w-full"
viewBox="0 0 466 309"
fill="currentColor"
>
<defs>
<mask
id={maskId}
x="-80"
y="-80"
width="626"
height="469"
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
>
<rect x="-80" y="-80" width="626" height="469" fill="#fff" />
<ellipse cx="193.3" cy="84.4" rx="27" ry="27" fill="#000" />
<ellipse cx="276" cy="84.4" rx="27" ry="27" fill="#000" />
<rect
x="166.3"
y="157.2"
width="136.9"
height="38.3"
rx="5"
fill="#000"
/>
<rect
x="166.9"
y="235.1"
width="136.2"
height="37.6"
rx="5"
fill="#000"
/>
</mask>
</defs>
<rect
x="128"
y="0"
width="210"
height="309"
rx="34"
mask={`url(#${maskId})`}
/>
</svg>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Cold-boot splash hold: on a real boot the workspace resolves in well under
// 100ms — before the hidden Tauri window ever puts a frame on screen — so the
// loading gate keeps the flapping bee up as an overlay above the already
// mounted app for a minimum visible duration, then fades out. E2E runs skip
// the hold by default (it would slow every spec's boot and block pointer
// actionability); this spec opts back in via __BUZZ_E2E__.bootSplashHoldMs.
test("boot splash overlay holds with a flapping bee, then dismisses", async ({
page,
}) => {
await installMockBridge(page);
// Registered after installMockBridge so it runs after the bridge's init
// script and can extend the config it assigns.
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { bootSplashHoldMs?: number };
};
testWindow.__BUZZ_E2E__ = {
...(testWindow.__BUZZ_E2E__ ?? {}),
bootSplashHoldMs: 1_500,
};
});
await page.goto("/");
const overlay = page.getByTestId("boot-splash-overlay");
await expect(overlay).toBeVisible();
// The bee is actually animating while the overlay holds — pure CSS, no SMIL.
const wingState = await overlay.locator(".bee-wing-left").evaluate((wing) => {
const animation = wing.getAnimations()[0];
return {
name: getComputedStyle(wing).animationName,
state: animation?.playState,
};
});
expect(wingState).toEqual({ name: "bee-wing-left-flap", state: "running" });
// The app mounts and loads beneath the overlay — boot is not delayed.
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
// After the hold elapses the overlay fades out and unmounts.
await expect(overlay).toHaveCount(0, { timeout: 6_000 });
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
});
test("boot splash overlay is skipped when the hold is zero (e2e default)", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await expect(page.getByTestId("boot-splash-overlay")).toHaveCount(0);
});
+34 -20
View File
@@ -1068,38 +1068,52 @@ test("first-run onboarding shows setup loading until Welcome bootstrap completes
await expect(loadingGate).toBeVisible();
await expect(loadingGate).toContainText("Setting up your workspace...");
// The boot gate is deliberately static: a plain Buzz mark in the brand
// yellow (#D7D72E) over solid black. The mark must paint complete on the
// FIRST frame — a blank gate reads as "nothing is loading" — so nothing
// about it may depend on SMIL/scripted animation, and the background must
// be a flat color rather than the animated gradient wash.
// The boot gate is the theme-adaptive grainient with the flapping Buzz bee
// as its hero. The mark must paint complete on the FIRST frame — a blank
// gate reads as "nothing is loading" — so nothing about it may depend on
// SMIL/scripted animation (<animate> count stays 0); the wing flap is pure
// CSS on HTML-level wing layers so it keeps running on the compositor even
// while boot work hogs the main thread.
await expect(
loadingGate.getByTestId("setup-grainient-background"),
).toBeVisible();
const mark = loadingGate.locator(".buzz-mark");
await expect(mark).toBeVisible();
const gateTreatment = await loadingGate.evaluate((element) => {
const shellStyles = window.getComputedStyle(element);
const markSvg = element.querySelector(".buzz-mark");
const markStyles =
markSvg instanceof SVGElement ? window.getComputedStyle(markSvg) : null;
const markElement = element.querySelector(".buzz-mark");
const markSvgs = markElement
? Array.from(markElement.querySelectorAll("svg"))
: [];
const wing = element.querySelector(".bee-wing");
const wingStyles =
wing instanceof SVGElement ? window.getComputedStyle(wing) : null;
const wash = element.querySelector(".buzz-setup-grainient__wash");
const washStyles =
wash instanceof HTMLElement ? window.getComputedStyle(wash) : null;
return {
animateElementCount: element.querySelectorAll("animate").length,
backgroundColor: shellStyles.backgroundColor,
backgroundImage: shellStyles.backgroundImage,
// The document itself must also be black (inline <style> in
// index.html) so the pre-React/pre-CSS first paint can't flash white
// before the gate mounts.
// The document itself must not flash white before the gate mounts
// (inline <style> in index.html; black fallback when no cached theme).
documentBackgroundColor: window.getComputedStyle(document.documentElement)
.backgroundColor,
markColor: markStyles?.color,
markUsesCurrentColor: markSvg?.getAttribute("fill") === "currentColor",
grainientAnimation: washStyles?.animationName,
grainientUsesRadialGradients:
washStyles?.backgroundImage.includes("radial-gradient"),
markSvgsUseCurrentColor:
markSvgs.length > 0 &&
markSvgs.every((svg) => svg.getAttribute("fill") === "currentColor"),
wingFlapAnimation: wingStyles?.animationName,
wingFlapRunning: wingStyles?.animationPlayState,
};
});
expect(gateTreatment).toEqual({
animateElementCount: 0,
backgroundColor: "rgb(0, 0, 0)",
backgroundImage: "none",
documentBackgroundColor: "rgb(0, 0, 0)",
markColor: "rgb(215, 215, 46)", // #d7d72e
markUsesCurrentColor: true,
grainientAnimation: "buzz-grainient-orbit",
grainientUsesRadialGradients: true,
markSvgsUseCurrentColor: true,
wingFlapAnimation: "bee-wing-left-flap",
wingFlapRunning: "running",
});
await expect(loadingGate).not.toHaveClass(/buzz-onboarding-neutral-theme/);
await expectShellHidden(page);