From f3fc17fdcccb65a42e50d9a5f5aacd7ceb9cb51c Mon Sep 17 00:00:00 2001 From: Diego Muracciole Date: Fri, 28 Aug 2026 01:27:41 +0200 Subject: [PATCH] feat(site): add a spinning-logo loading state to the playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground chunk is large enough to leave a visible gap, and the old fallback was a line of centered text that cut away the instant the chunk landed. Replace it with a full-page overlay: the logo spinning above a "Loading playground…" label, held for a minimum beat so a fast load reads as intentional rather than a flicker, then faded out. The overlay sits over a mounted playground instead of swapping with it, so CodeMirror and the first PDF render happen behind it rather than popping in after. The same visual backs the Suspense fallback, so the server HTML and the client overlay are one picture. Co-Authored-By: Claude Opus 5 (1M context) --- apps/site/app/playground/loader.tsx | 76 +++++++++++++++++++ apps/site/app/playground/page.tsx | 9 +-- .../site/app/playground/playground-client.tsx | 29 ++++--- 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 apps/site/app/playground/loader.tsx 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 ( + <> + + + + ); }