mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
Nebula: fix mobile jump + pointer parallax, live cursor, lighter mobile field
- Mobile jump fix: compute the monitor anchor from the title's document-absolute top (scroll-independent) and only recompute on a real width change, so the address-bar resize that fires mid-scroll no longer snaps it. Mobile parallax now lifts the field up and away (faster than scroll) instead of drifting down over the text. - Pointer parallax (desktop only): the field nudges toward the cursor, spring-damped so it can never move abruptly. No mousemove on touch, so no mobile cost. - Live monitor: a CSS blinking terminal cursor inside the screen (opacity only = compositor); it shifts color on each streamer hit, reinforcing the impact pulse. - Lighter mobile field: streamer density 8->5 and lines ~15% thinner on phones. Desktop motion unchanged. (CSS/SMIL streamer rewrite deferred to its own commit.)
This commit is contained in:
@@ -232,4 +232,8 @@
|
|||||||
/* Splash logo zoom on click-to-enter (CSS, so the logo needs no framer-motion
|
/* Splash logo zoom on click-to-enter (CSS, so the logo needs no framer-motion
|
||||||
wrapper and stays a clean LCP candidate). */
|
wrapper and stays a clean LCP candidate). */
|
||||||
@keyframes splash-zoom { 0% { transform: scale(1); } 50% { transform: scale(1.06); } 100% { transform: scale(1.14); } }
|
@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; }
|
.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; }
|
||||||
@@ -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.
|
// 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.
|
// The title lives in the server-rendered children, so read it by id.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let lastWidth = window.innerWidth;
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
const title = document.getElementById("hero-title");
|
const title = document.getElementById("hero-title");
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
const rect = title.getBoundingClientRect();
|
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 titleLeftPercent = (rect.left / window.innerWidth) * 100;
|
||||||
const titleTopPercent = (rect.top / window.innerHeight) * 100;
|
const titleTopPercent = ((rect.top + window.scrollY) / window.innerHeight) * 100;
|
||||||
setNebulaPosition({
|
setNebulaPosition({
|
||||||
x: Math.max(5, titleLeftPercent - 8),
|
x: Math.max(5, titleLeftPercent - 8),
|
||||||
y: Math.max(10, titleTopPercent - 2),
|
y: Math.max(10, titleTopPercent - 2),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
updatePosition();
|
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);
|
document.fonts?.ready.then(updatePosition);
|
||||||
return () => window.removeEventListener("resize", updatePosition);
|
return () => window.removeEventListener("resize", onResize);
|
||||||
}, [entered]);
|
}, [entered]);
|
||||||
|
|
||||||
const enterFromButton = () => {
|
const enterFromButton = () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Monitor } from "lucide-react";
|
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
|
// Desktop: gentle downward parallax. The monitor lives in the empty left gutter, so
|
||||||
// drifting down stays clear of text.
|
// drifting down stays clear of text.
|
||||||
const yRange = useTransform(scrollY, [0, 1000], [0, 250]);
|
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
|
// Mobile: there is no gutter, so instead of pinning the field to the screen we lift it
|
||||||
// glide up and out with the hero (slower than scroll, which gives the same depth) and
|
// up and away as you scroll (a touch faster than the scroll, so it rises off the top
|
||||||
// fade, so it never crosses the content below. PARALLAX/FADE ranges are easy dials.
|
// rather than drifting down over the text below) and fade it out. The ranges are dials.
|
||||||
const yRangeMobile = useTransform(scrollY, [0, 1000], [0, -650]);
|
const yRangeMobile = useTransform(scrollY, [0, 1000], [0, -1100]);
|
||||||
const parallaxY = isMobile ? yRangeMobile : yRange;
|
const parallaxY = isMobile ? yRangeMobile : yRange;
|
||||||
const heroOpacity = useTransform(scrollY, [0, 450], [1, 0]);
|
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
|
// 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.
|
// top room the hero gets on mobile (see page.tsx) keeps this clear of the header.
|
||||||
const computerX = position?.x ?? 18;
|
const computerX = position?.x ?? 18;
|
||||||
@@ -110,6 +118,9 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
|||||||
const targetX = computerX + 1.5;
|
const targetX = computerX + 1.5;
|
||||||
const targetY = computerY + 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
|
// Check for reduced motion preference
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
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);
|
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
|
// Sync color with theme
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prefersReducedMotion) {
|
if (!prefersReducedMotion) {
|
||||||
@@ -138,7 +161,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
|||||||
}, [themeMode, prefersReducedMotion]);
|
}, [themeMode, prefersReducedMotion]);
|
||||||
|
|
||||||
const paths = useMemo(() => {
|
const paths = useMemo(() => {
|
||||||
return Array.from({ length: density }).map(() => {
|
return Array.from({ length: effectiveDensity }).map(() => {
|
||||||
const edge = Math.floor(Math.random() * 4);
|
const edge = Math.floor(Math.random() * 4);
|
||||||
let startX = 50, startY = 50;
|
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}`;
|
return `M ${startX} ${startY} Q ${cpX} ${cpY} ${endX} ${endY}`;
|
||||||
});
|
});
|
||||||
}, [density, themeMode, targetX, targetY]);
|
}, [effectiveDensity, themeMode, targetX, targetY]);
|
||||||
|
|
||||||
const handleHit = useCallback(() => {
|
const handleHit = useCallback(() => {
|
||||||
const colors = themeMode === 'light'
|
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
|
{/* Monitor + streamers + glow. On mobile this layer glides up and fades as the
|
||||||
hero scrolls away, so it never crosses the content below. */}
|
hero scrolls away, so it never crosses the content below. */}
|
||||||
<motion.div className="absolute inset-0" style={{ opacity: isMobile ? heroOpacity : undefined }}>
|
<motion.div className="absolute inset-0" style={{ opacity: isMobile ? heroOpacity : undefined, x: pointerX, y: pointerY }}>
|
||||||
|
|
||||||
{/* Slowly-pulsing Cherenkov "reactor pool" bloom */}
|
{/* Slowly-pulsing Cherenkov "reactor pool" bloom */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -240,13 +263,14 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
|||||||
y: parallaxY
|
y: parallaxY
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div animate={monitorControls}>
|
<motion.div animate={monitorControls} className={cn("relative transition-colors duration-1000", monitorColor)}>
|
||||||
<Monitor
|
<Monitor size={56} />
|
||||||
size={56} // Bigger icon as requested
|
{/* Live-terminal blink inside the screen. CSS opacity only, so it runs on the
|
||||||
className={cn(
|
compositor for essentially no main-thread cost. */}
|
||||||
"transition-colors duration-1000", // Back to smoother color shift
|
<span
|
||||||
monitorColor
|
aria-hidden="true"
|
||||||
)}
|
className="nebula-cursor pointer-events-none absolute left-1/2 top-[38%] h-[3px] w-[9px] -translate-x-1/2 rounded-[1px]"
|
||||||
|
style={{ backgroundColor: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -263,7 +287,7 @@ export const NebulaField = ({ className, density = 8, themeMode = 'dark', positi
|
|||||||
d={d}
|
d={d}
|
||||||
themeMode={themeMode}
|
themeMode={themeMode}
|
||||||
onHit={handleHit}
|
onHit={handleHit}
|
||||||
width={themeMode === 'light' ? 0.08 : 0.12}
|
width={(themeMode === 'light' ? 0.08 : 0.12) * (isMobile ? 0.85 : 1)}
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user