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:
jwinterm
2026-06-07 14:01:31 -04:00
parent 673d07a00e
commit 4b21928dc4
3 changed files with 60 additions and 20 deletions
+5 -1
View File
@@ -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; }
.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; }
+15 -3
View File
@@ -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 = () => {