- {/* First-visit cover for the what's-new tour: solid midnight painted with
- the initial HTML, hidden synchronously (same parse pass, before paint)
- for anyone who has already seen the tour. First-timers keep it until
- FeatureTour mounts and takes over with the identical color, so home
- never flashes before the thank-you screen. HIDDEN, never removed: the
- server and client trees must stay structurally identical or hydration
- fails (suppressHydrationWarning covers the style the script mutates). */}
-
-
-
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index b1878a6..35e2e0a 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -47,27 +47,19 @@ export default function AppShell({
} catch {}
}, []);
- // The tour auto-plays exactly once. If it should NOT play, the pre-hydration
- // cover was already removed synchronously by the inline script; if the tour
- // chunk somehow never arrives, the safety timer clears the cover anyway so
- // nobody stares at a blank screen. Deferred set (like seen-home above) so it
- // can't cascade a render.
+ // The tour auto-plays exactly once. Mounted immediately even though it stays
+ // invisible at first: its demo stage is a whole scout page, and rendering
+ // that behind a transparent overlay is what lets the reveal be a clean fade
+ // instead of a mount. The hold on home is the tour's own (see FeatureTour).
+ // Deferred set (like seen-home above) so it can't cascade a render.
useEffect(() => {
let show = false;
try {
show = !localStorage.getItem(TOUR_STORAGE_KEY);
} catch {}
- const t = setTimeout(() => {
- if (show) setTourOpen(true);
- }, 0);
- const safety = setTimeout(() => {
- const cover = document.getElementById("gf-tour-cover");
- if (cover) cover.style.display = "none";
- }, 4000);
- return () => {
- clearTimeout(t);
- clearTimeout(safety);
- };
+ if (!show) return;
+ const t = setTimeout(() => setTourOpen(true), 0);
+ return () => clearTimeout(t);
}, []);
// Scouting navigates to the canonical / route. The transition keeps
diff --git a/components/tour/FeatureTour.tsx b/components/tour/FeatureTour.tsx
index c9ce1ba..22884ad 100644
--- a/components/tour/FeatureTour.tsx
+++ b/components/tour/FeatureTour.tsx
@@ -23,6 +23,8 @@ const ZOOM_OUT_MS = 900; // the pull back to the whole page
const OPEN_HOLD = 1500; // look at the scout page before the first dive
const WIDE_HOLD = 1000; // breathe at full view between stops
const EXIT_MS = 380;
+const HOME_HOLD = 850; // home is seen plainly first, then the dark washes over it
+const ENTER_MS = 550; // that wash
const CAP_GAP = 14; // caption sits close to the spotlight, part of the same beat
interface Cam {
@@ -79,24 +81,28 @@ const demoCard = () => SAMPLE_CARDS.find((c) => c.login === "torvalds") ?? SAMPL
const prefersReduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
-// Eased count-up; renders the target outright under reduced motion.
+// Eased count-up; renders the target outright under reduced motion. `run`
+// holds it at zero until the screen is actually visible, so the climb isn't
+// spent behind the fade.
function CountUp({
to,
decimals = 0,
prefix = "",
suffix = "",
delay = 0,
+ run,
}: {
to: number;
decimals?: number;
prefix?: string;
suffix?: string;
delay?: number;
+ run: boolean;
}) {
const [reduced] = useState(prefersReduced);
const [v, setV] = useState(0);
useEffect(() => {
- if (reduced) return;
+ if (reduced || !run) return;
let raf = 0;
let start = 0;
const DUR = 1600;
@@ -108,7 +114,7 @@ function CountUp({
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
- }, [to, delay, reduced]);
+ }, [to, delay, reduced, run]);
return (
{prefix}
@@ -124,6 +130,7 @@ export default function FeatureTour({ onDone }: { onDone: () => void }) {
const [caption, setCaption] = useState
(null);
const [thanksIdx, setThanksIdx] = useState(0);
const [ctaIn, setCtaIn] = useState(false);
+ const [shown, setShown] = useState(false);
// Resolved once on mount (ssr:false, so window exists): drop the ball stop
// where the ball itself never mounts, and flatten motion when asked to.
const [steps] = useState(() =>
@@ -153,28 +160,29 @@ export default function FeatureTour({ onDone }: { onDone: () => void }) {
setTimeout(onDone, EXIT_MS + 30);
}, [onDone]);
- // The pre-hydration cover (app/page.tsx) bridged SSR paint -> here; the
- // intro is the same solid midnight, so hiding it now is seamless. Hidden,
- // not removed — React owns that node and the trees must keep matching.
+ // Let home stand on its own for a beat, then wash over it. Everything else
+ // in the intro is timed off `shown`, not off mount, so the count-up and the
+ // CTA still land against the moment a visitor can actually see them.
useEffect(() => {
- const cover = document.getElementById("gf-tour-cover");
- if (cover) cover.style.display = "none";
+ const t = setTimeout(() => setShown(true), HOME_HOLD);
+ return () => clearTimeout(t);
}, []);
// Cycle the thank-you through its languages while the intro is up.
useEffect(() => {
- if (phase !== "intro" || reduced) return;
+ if (phase !== "intro" || !shown || reduced) return;
const id = setInterval(() => setThanksIdx((i) => (i + 1) % THANKS.length), THANKS_MS);
return () => clearInterval(id);
- }, [phase, reduced]);
+ }, [phase, shown, reduced]);
// Hold the way in back until the thank-you has had its moment. It stays in
// the layout the whole time (only opacity moves), so nothing shifts when it
// arrives.
useEffect(() => {
+ if (!shown) return;
const t = setTimeout(() => setCtaIn(true), CTA_DELAY);
return () => clearTimeout(t);
- }, []);
+ }, [shown]);
useEffect(() => clearTimers, []);
@@ -310,7 +318,12 @@ export default function FeatureTour({ onDone }: { onDone: () => void }) {
return (
{/* ---- the demo stage: a real, live scout page under the camera ---- */}
void }) {
{/* ---- intro: the thank-you moment ---- */}
{phase === "intro" && (
-
- {/* one soft, slowly breathing wash — the only decoration */}
- {/* centering lives in the transform (and its keyframe) alone — a
- Tailwind -translate-* here sets the separate `translate` property
- and would compose with it, shoving the glow off-screen */}
+ <>
+ {/* the black is its own sheet, opaque from the first frame so the demo
+ stage never shows through. The root's fade carries it over home;
+ the copy above trails by a beat, so the words resolve into midnight
+ instead of crossfading through the home page underneath. */}
+
-
-
- ONE MONTH OF GITFUT
-
+ >
+ {/* one soft, slowly breathing wash — the only decoration */}
+ {/* centering lives in the transform (and its keyframe) alone — a
+ Tailwind -translate-* here sets the separate `translate` property
+ and would compose with it, shoving the glow off-screen */}
+
- {/* every language stacked in a single grid cell: the block keeps one
- width no matter which word is showing */}
-
- {THANKS.map((w, i) => (
-
- {w.text}
-
- ))}
-
+
+ ONE MONTH OF GITFUT
+
- {/* no divider above the numbers: the only rule on this screen is the
- one under the CTA, so a line means "click me" and nothing else */}
-
- {STATS.map((s) => (
-
-
-
-
-
- {s.label}
+ {/* every language stacked in a single grid cell: the block keeps one
+ width no matter which word is showing */}
+
+ {THANKS.map((w, i) => (
+
+ {w.text}
-
- ))}
-
+ ))}
+
- {/* the way in, in the same mono micro-caps voice as the eyebrow and
- the stat labels, closed by a green hairline answering the gold one
- above the numbers — the one live thing on a still screen */}
-
+
+ {/* the way in, in the same mono micro-caps voice as the eyebrow and
+ the stat labels, closed by a green hairline — the one live thing
+ on a still screen */}
+
-
-
+ >
+
+ SEE WHAT WE BUILT
+
+ {/* draws itself open under the words as they land: the one bit of
+ choreography on a still screen, and the only thing telling you
+ this line is a control */}
+
+
+
+ >
)}
{/* ---- tour chrome: caption hugging the spotlight, controls docked ---- */}