From a76df62fcca311571adc0e8f065dc0813558d702 Mon Sep 17 00:00:00 2001 From: wondopamine Date: Mon, 25 May 2026 08:18:58 +0800 Subject: [PATCH] feat(onboarding): populated island + live arc orbit on the signed-out landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-out visitors land on /onboarding behind the EdupassLogin surface — previously a sparse mailbox-and-telescope island that read as "is this thing on?" instead of "look what you can grow." Two pieces here: A new `landingShowcase` prop on EngineHost (defaulting false so the production onboarding ceremony is untouched) calls view.flowers.showAll + view.tree.showAll + view.butterflies.showAll once the engine is ready, and the cleanup re-hides them so the sign-in transition lands cleanly on the intentional empty stage. Wired from _app.tsx's signed- out branch. Camera was meant to slowly orbit the island via camera.startLandingOrbit, but _app.tsx was passing `camera={null}` and the JSX-bare `reducedMotion` prop (which evaluates to true) into EdupassLogin — so the orbit never started, regardless of the preset. SignedOutOnboarding now pulls the live engine through useEngine(), hands EdupassLogin the real `view.camera`, and resolves prefers-reduced-motion through a media-query hook instead of a hardcoded true. The login-orbit preset bumps from 1°/s to 3°/s (~120s per rotation, the speed where the sweep is unmistakable but never dizzying), drops pitch from 35° to 28° for a more horizon-level framing, and trims distance from 33.9 to 32 so the populated island fills more of the frame. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/student-space/EngineHost.tsx | 38 +++++++++++++++++++++ src/lib/student-space/camera-tuner.ts | 9 +++-- src/routes/_app.tsx | 23 +++++++++++-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/components/student-space/EngineHost.tsx b/src/components/student-space/EngineHost.tsx index 90da055..3cae451 100644 --- a/src/components/student-space/EngineHost.tsx +++ b/src/components/student-space/EngineHost.tsx @@ -51,11 +51,17 @@ export function EngineHost({ children, showOnboardingFlow = true, hideCompanion = false, + landingShowcase = false, }: { className?: string children?: ReactNode showOnboardingFlow?: boolean hideCompanion?: boolean + // When true, populate the island (all flowers, the tree, butterflies) + // so signed-out visitors see a mature island instead of the sparse + // one. Reverts on cleanup so a sign-in transition lands cleanly on + // the empty onboarding stage. + landingShowcase?: boolean }) { const containerRef = useRef(null) const [error, setError] = useState(null) @@ -156,6 +162,38 @@ export function EngineHost({ view?.fruits?.hideAll?.() }, [game]) + // Landing-page showcase: when a signed-out visitor is on /onboarding the + // engine renders behind the login surface. Surface every flower, the + // tree, and the full butterfly count so the preview reads as "what a + // mature island looks like" rather than the sparse onboarding start + // state. Cleanup re-hides them so a sign-in transition drops cleanly + // onto the empty onboarding stage. + useEffect(() => { + if (!game || !landingShowcase) return + const view = ( + game as unknown as { + view?: { + flowers?: { showAll?: () => void; hideAll?: () => void } + tree?: { showAll?: () => void; hideAll?: () => void } + butterflies?: { + showAll?: () => void + hideAll?: () => void + showCount?: (n: number) => void + } + } + } + ).view + if (!view) return + view.flowers?.showAll?.() + view.tree?.showAll?.() + view.butterflies?.showAll?.() + return () => { + view.flowers?.hideAll?.() + view.tree?.hideAll?.() + view.butterflies?.hideAll?.() + } + }, [game, landingShowcase]) + useEffect(() => { const container = containerRef.current if (!container) return diff --git a/src/lib/student-space/camera-tuner.ts b/src/lib/student-space/camera-tuner.ts index c401c31..7f2c7b3 100644 --- a/src/lib/student-space/camera-tuner.ts +++ b/src/lib/student-space/camera-tuner.ts @@ -131,9 +131,12 @@ export const DEFAULT_PRESETS: Readonly = Object.freeze({ durationMs: 800, }, 'login-orbit': { - azimuthDegPerSec: 1, - distance: 33.9, - pitchDeg: 35, + // Slow, cinematic arc — ~3°/s reads as ambient sweep without the + // dizziness a faster rotation would introduce; the populated island + // gets to show off over the dwell time on the login surface. + azimuthDegPerSec: 3, + distance: 32, + pitchDeg: 28, }, }) diff --git a/src/routes/_app.tsx b/src/routes/_app.tsx index ed7632e..61b20bd 100644 --- a/src/routes/_app.tsx +++ b/src/routes/_app.tsx @@ -1,6 +1,8 @@ import { createFileRoute, Outlet, redirect, useLocation } from '@tanstack/react-router' +import { useEffect, useState } from 'react' import { EngineHost } from '~/components/student-space/EngineHost' import { EdupassLogin } from '~/components/student-space/onboarding/EdupassLogin' +import { useEngine } from '~/lib/student-space/use-engine' import { loadAuthMenu } from '~/server/auth-menu.functions' import type { AuthMenuState } from '~/server/auth-menu.handler.server' @@ -51,7 +53,7 @@ function AppLayout() { if (isOnboardingPath(location.pathname) && authMenu?.status !== 'signed-in') { return ( - + ) @@ -73,9 +75,26 @@ export function isOnboardingPath(pathname: string) { } function SignedOutOnboarding() { + const game = useEngine() + // biome-ignore lint/suspicious/noExplicitAny: engine view bag is untyped. + const camera = (game as any)?.view?.camera ?? null + const reducedMotion = useReducedMotion() return (
- +
) } + +function useReducedMotion() { + const [reduced, setReduced] = useState(false) + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + setReduced(mq.matches) + const handler = (e: MediaQueryListEvent) => setReduced(e.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + return reduced +}