mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
Claude Fable 5
parent
3e982fd371
commit
c145037aad
+36
-6
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"maximized": true,
|
||||
"visible": false,
|
||||
"visible": true,
|
||||
"backgroundColor": "#000000",
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
|
||||
+116
-7
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user