+ {/* 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 b89aaa6..b1878a6 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -11,6 +11,7 @@ import BuyMeACoffee from "@/components/BuyMeACoffee";
import SupportProductHunt from "@/components/SupportProductHunt";
import GithubStar from "@/components/GithubStar";
import { SAMPLE_CARDS } from "@/lib/github/samples";
+import { TOUR_STORAGE_KEY } from "@/components/tour/steps";
const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), {
ssr: false,
@@ -18,6 +19,11 @@ const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), {
// Home-only: AppShell is rendered solely by app/page.tsx, so the TEAM NEWS
// bulletin never mounts on scout/duel pages. Lazy + ssr:false like the modal.
const WhatsNew = dynamic(() => import("@/components/WhatsNew"), { ssr: false });
+// First-visit what's-new tour (thank-you + zoom showcase). Mounted over home as
+// a pure overlay — no navigation in or out — and only until it has been seen
+// once (localStorage; the pre-hydration cover in app/page.tsx hides home's
+// first paint for exactly the visitors who will get it).
+const FeatureTour = dynamic(() => import("@/components/tour/FeatureTour"), { ssr: false });
export default function AppShell({
stars,
@@ -30,6 +36,7 @@ export default function AppShell({
const [isPending, startTransition] = useTransition();
const [pending, setPending] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
+ const [tourOpen, setTourOpen] = useState(false);
// Mark this tab as "has visited home" so a scouted card shows BACK, while a
// directly-opened / shared card link (no home visit) shows a "make your card"
@@ -40,6 +47,29 @@ 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.
+ 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);
+ };
+ }, []);
+
// Scouting navigates to the canonical / route. The transition keeps
// the loading screen up (with the mascot + puns) while the report is fetched
// and server-rendered; the route then plays its own reveal.
@@ -80,6 +110,8 @@ export default function AppShell({
{modalOpen && setModalOpen(false)} />}
+
+ {tourOpen && setTourOpen(false)} />}
>
);
}
diff --git a/components/BallCursor.tsx b/components/BallCursor.tsx
index 08816ea..7c6aaf7 100644
--- a/components/BallCursor.tsx
+++ b/components/BallCursor.tsx
@@ -167,6 +167,7 @@ export default function BallCursor() {
return (