diff --git a/apps/site/app/playground/loader.tsx b/apps/site/app/playground/loader.tsx
new file mode 100644
index 000000000..ed994906a
--- /dev/null
+++ b/apps/site/app/playground/loader.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import Image from 'next/image';
+import { useEffect, useState } from 'react';
+
+import logo from '@/public/logo.png';
+
+// The chunk often lands in a couple hundred ms, which reads as a flicker
+// rather than a load; hold the overlay long enough for it to look intentional.
+const MIN_VISIBLE_MS = 650;
+const FADE_MS = 400;
+
+export function LoaderVisual() {
+ return (
+
+
+
+ Loading playground…
+
+
+ );
+}
+
+/**
+ * Covers the page until `ready` resolves, then fades out. Sits over the
+ * playground rather than swapping with it, so the editor and the first PDF
+ * render happen behind the overlay instead of popping in after it.
+ */
+export function PlaygroundLoader({ ready }: { ready: Promise }) {
+ const [done, setDone] = useState(false);
+ const [gone, setGone] = useState(false);
+
+ useEffect(() => {
+ let alive = true;
+ const held = new Promise((resolve) => setTimeout(resolve, MIN_VISIBLE_MS));
+
+ Promise.all([ready, held]).then(() => {
+ if (alive) setDone(true);
+ });
+
+ return () => {
+ alive = false;
+ };
+ }, [ready]);
+
+ useEffect(() => {
+ if (!done) return undefined;
+ // not `transitionend`: it never fires when the user prefers reduced motion
+ const timer = setTimeout(() => setGone(true), FADE_MS);
+ return () => clearTimeout(timer);
+ }, [done]);
+
+ if (gone) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/site/app/playground/page.tsx b/apps/site/app/playground/page.tsx
index b54a246f5..c7d9d5931 100644
--- a/apps/site/app/playground/page.tsx
+++ b/apps/site/app/playground/page.tsx
@@ -1,18 +1,13 @@
import { Suspense } from 'react';
+import { LoaderVisual } from './loader';
import { PlaygroundClient } from './playground-client';
export const metadata = { title: 'Playground' };
export default function PlaygroundPage() {
return (
-
- Loading playground…
-
- }
- >
+ }>
);
diff --git a/apps/site/app/playground/playground-client.tsx b/apps/site/app/playground/playground-client.tsx
index 60f0ca8ff..9a2053d5d 100644
--- a/apps/site/app/playground/playground-client.tsx
+++ b/apps/site/app/playground/playground-client.tsx
@@ -1,19 +1,24 @@
'use client';
import dynamic from 'next/dynamic';
+import { useState } from 'react';
-const Playground = dynamic(
- () => import('@/components/playground').then((m) => m.Playground),
- {
- ssr: false,
- loading: () => (
-
- Loading playground…
-
- ),
- },
-);
+import { PlaygroundLoader } from './loader';
+
+const load = () => import('@/components/playground').then((m) => m.Playground);
+
+// `loading` is skipped: the overlay below owns the wait, so that it can outlive
+// the chunk arriving and fade out instead of cutting.
+const Playground = dynamic(load, { ssr: false, loading: () => null });
export function PlaygroundClient() {
- return ;
+ // module promises are cached, so this is the same load `dynamic` awaits
+ const [ready] = useState(load);
+
+ return (
+ <>
+
+
+ >
+ );
}