diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 7ca34cf..69f7e12 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -232,4 +232,8 @@ /* Splash logo zoom on click-to-enter (CSS, so the logo needs no framer-motion wrapper and stays a clean LCP candidate). */ @keyframes splash-zoom { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1.14); } } -.splash-zoom { animation: splash-zoom 0.8s ease-in-out forwards; } \ No newline at end of file +.splash-zoom { animation: splash-zoom 0.8s ease-in-out forwards; } + +/* Blinking terminal cursor inside the nebula monitor (opacity only = compositor). */ +@keyframes nebula-cursor-blink { 0%, 49% { opacity: 0.85; } 50%, 100% { opacity: 0; } } +.nebula-cursor { animation: nebula-cursor-blink 1.1s steps(1, end) infinite; } \ No newline at end of file diff --git a/apps/web/src/components/landing/home-shell.tsx b/apps/web/src/components/landing/home-shell.tsx index c73ba48..0f272d7 100644 --- a/apps/web/src/components/landing/home-shell.tsx +++ b/apps/web/src/components/landing/home-shell.tsx @@ -73,21 +73,33 @@ export function HomeShell({ children }: { children: ReactNode }) { // Place the nebula/monitor just to the left of the hero title, at any screen width. // The title lives in the server-rendered children, so read it by id. useEffect(() => { + let lastWidth = window.innerWidth; const updatePosition = () => { const title = document.getElementById("hero-title"); if (!title) return; const rect = title.getBoundingClientRect(); + // Use the title's document-absolute top (rect.top + scrollY), not its + // viewport-relative top, so the value does not shift as the page scrolls. + // Mobile browsers fire `resize` on scroll when the address bar hides, and a + // viewport-relative read at that moment is what made the monitor jump. const titleLeftPercent = (rect.left / window.innerWidth) * 100; - const titleTopPercent = (rect.top / window.innerHeight) * 100; + const titleTopPercent = ((rect.top + window.scrollY) / window.innerHeight) * 100; setNebulaPosition({ x: Math.max(5, titleLeftPercent - 8), y: Math.max(10, titleTopPercent - 2), }); }; updatePosition(); - window.addEventListener("resize", updatePosition); + // Recompute only on a real width change (orientation / window resize). Ignore + // height-only changes, which on mobile are just the address bar showing or hiding. + const onResize = () => { + if (window.innerWidth === lastWidth) return; + lastWidth = window.innerWidth; + updatePosition(); + }; + window.addEventListener("resize", onResize); document.fonts?.ready.then(updatePosition); - return () => window.removeEventListener("resize", updatePosition); + return () => window.removeEventListener("resize", onResize); }, [entered]); const enterFromButton = () => { diff --git a/packages/ui/src/procedural/nebula-field.tsx b/packages/ui/src/procedural/nebula-field.tsx index 81d7256..00c00b6 100644 --- a/packages/ui/src/procedural/nebula-field.tsx +++ b/packages/ui/src/procedural/nebula-field.tsx @@ -1,6 +1,6 @@ 'use client'; -import { motion, useAnimation, useScroll, useTransform } from "framer-motion"; +import { motion, useAnimation, useScroll, useTransform, useMotionValue, useSpring } from "framer-motion"; import { useEffect, useState, useCallback, useMemo } from "react"; import { cn } from "../lib/utils"; import { Monitor } from "lucide-react"; @@ -93,13 +93,21 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi // Desktop: gentle downward parallax. The monitor lives in the empty left gutter, so // drifting down stays clear of text. const yRange = useTransform(scrollY, [0, 1000], [0, 250]); - // Mobile: there is no gutter, so instead of pinning the field to the screen we let it - // glide up and out with the hero (slower than scroll, which gives the same depth) and - // fade, so it never crosses the content below. PARALLAX/FADE ranges are easy dials. - const yRangeMobile = useTransform(scrollY, [0, 1000], [0, -650]); + // Mobile: there is no gutter, so instead of pinning the field to the screen we lift it + // up and away as you scroll (a touch faster than the scroll, so it rises off the top + // rather than drifting down over the text below) and fade it out. The ranges are dials. + const yRangeMobile = useTransform(scrollY, [0, 1000], [0, -1100]); const parallaxY = isMobile ? yRangeMobile : yRange; const heroOpacity = useTransform(scrollY, [0, 450], [1, 0]); + // Pointer parallax (desktop only): the whole field nudges a few px toward the + // cursor. Spring-smoothed, so it can never move abruptly. Touch devices have no + // mousemove, so there is no mobile cost. + const pointerXRaw = useMotionValue(0); + const pointerYRaw = useMotionValue(0); + const pointerX = useSpring(pointerXRaw, { stiffness: 60, damping: 18, mass: 0.4 }); + const pointerY = useSpring(pointerYRaw, { stiffness: 60, damping: 18, mass: 0.4 }); + // Monitor position: just to the left of the hero title, on every screen. The extra // top room the hero gets on mobile (see page.tsx) keeps this clear of the header. const computerX = position?.x ?? 18; @@ -110,6 +118,9 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi const targetX = computerX + 1.5; const targetY = computerY + 1.5; + // Fewer, slightly thinner streamers on mobile for a cleaner read on a small screen. + const effectiveDensity = isMobile ? 5 : density; + // Check for reduced motion preference useEffect(() => { const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); @@ -130,6 +141,18 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi return () => mq.removeEventListener('change', handler); }, []); + // Pointer parallax driver (desktop only). Spring-smoothed via pointerX/Y above. + useEffect(() => { + if (isMobile) return; + const MAX = 12; // px of nudge at the screen edges + const onMove = (e: MouseEvent) => { + pointerXRaw.set(((e.clientX / window.innerWidth) - 0.5) * 2 * MAX); + pointerYRaw.set(((e.clientY / window.innerHeight) - 0.5) * 2 * MAX); + }; + window.addEventListener('mousemove', onMove); + return () => window.removeEventListener('mousemove', onMove); + }, [isMobile, pointerXRaw, pointerYRaw]); + // Sync color with theme useEffect(() => { if (!prefersReducedMotion) { @@ -138,7 +161,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi }, [themeMode, prefersReducedMotion]); const paths = useMemo(() => { - return Array.from({ length: density }).map(() => { + return Array.from({ length: effectiveDensity }).map(() => { const edge = Math.floor(Math.random() * 4); let startX = 50, startY = 50; @@ -156,7 +179,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi return `M ${startX} ${startY} Q ${cpX} ${cpY} ${endX} ${endY}`; }); - }, [density, themeMode, targetX, targetY]); + }, [effectiveDensity, themeMode, targetX, targetY]); const handleHit = useCallback(() => { const colors = themeMode === 'light' @@ -215,7 +238,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi {/* Monitor + streamers + glow. On mobile this layer glides up and fades as the hero scrolls away, so it never crosses the content below. */} - + {/* Slowly-pulsing Cherenkov "reactor pool" bloom */} - - + + {/* Live-terminal blink inside the screen. CSS opacity only, so it runs on the + compositor for essentially no main-thread cost. */} + @@ -263,7 +287,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi d={d} themeMode={themeMode} onHit={handleHit} - width={themeMode === 'light' ? 0.08 : 0.12} + width={(themeMode === 'light' ? 0.08 : 0.12) * (isMobile ? 0.85 : 1)} index={i} /> ))}