From 26aa908450bf80d796d275b34f2a284898131b8d Mon Sep 17 00:00:00 2001 From: dommango-sys <251805093+dommango@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:50:30 -0400 Subject: [PATCH 1/2] feat: add scroll-driven and entrance motion to landing sections BinaryRule wipes in with scroll, a top progress rule tracks the page, the resume timeline spine draws, project cards stamp in, and continent bars fill with ticking counts. All gated to prefers-reduced-motion: no-preference, with scroll timelines behind @supports so unsupported browsers show final state. Co-Authored-By: Claude Sonnet 5.5 --- app/globals.css | 80 ++++++++++++++++++++++++- components/landing/BrutalistLanding.tsx | 1 + components/landing/CountUp.tsx | 30 ++++++++++ components/landing/Projects.tsx | 15 +++-- components/landing/RevealGroup.tsx | 16 +++++ components/landing/Travel.tsx | 18 ++++-- components/landing/useInView.ts | 28 +++++++++ 7 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 components/landing/CountUp.tsx create mode 100644 components/landing/RevealGroup.tsx create mode 100644 components/landing/useInView.ts diff --git a/app/globals.css b/app/globals.css index 40baff4e..471299d1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -289,6 +289,75 @@ input, .brutalist-root[data-contrast="high"] :focus-visible { outline: 3px solid #fff; outline-offset: 3px; box-shadow: 0 0 0 6px #000; } .field input:focus-visible, .field textarea:focus-visible { outline-offset: 6px; border-bottom-width: 2px; } +/* ---- Scroll motion ---------------------------------------- * + * Two techniques. Scrubbed (CSS scroll timelines) follows scroll position and + * is progressive enhancement: without support the element just shows its final + * state. One-shot (`.is-in`, added by useInView) plays once on entry; the hidden + * start state applies only when scripting is on, so no-JS never sees blank + * content. Both are fenced to no-preference — scroll timelines ignore the + * duration-shrinking reduced-motion rule below, so they must be opted out. */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: scroll()) { + .scroll-progress { + position: fixed; + inset: 0 0 auto 0; + height: 3px; + background: var(--accent); + transform: scaleX(0); + transform-origin: left; + z-index: 55; /* above .site-nav (50) */ + pointer-events: none; + animation: scroll-progress linear both; + animation-timeline: scroll(root); + } + } + @supports (animation-timeline: view()) { + .binary-rule { + animation: rule-wipe steps(14, end) both; + animation-timeline: view(); + animation-range: entry 10% entry 100%; + } + .timeline::before { + content: ""; + position: absolute; + left: 4px; + top: 0; + bottom: 0; + width: 2px; + background: var(--accent); + transform-origin: top; + animation: timeline-spine linear both; + animation-timeline: view(); + animation-range: entry 30% cover 55%; + } + } + @media (scripting: enabled) { + .projects-grid:not(.is-in) .work-card { clip-path: inset(0 0 100% 0); } + .continent-list:not(.is-in) .continent-fill { transform: scaleX(0); } + .continent-list:not(.is-in) .continent-count { opacity: 0; } + } + .projects-grid.is-in .work-card { + /* `backwards` only: a forwards fill would pin transform and kill the hover lift. */ + animation: card-stamp var(--dur-slow) var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 70ms); + } + .continent-list.is-in .continent-fill { + animation: bar-fill var(--dur-slow) var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 60ms); + } +} +@keyframes scroll-progress { to { transform: scaleX(1); } } +@keyframes rule-wipe { + from { clip-path: inset(0 100% 0 0); color: var(--accent-text); } + to { clip-path: inset(0 0 0 0); } +} +@keyframes timeline-spine { from { transform: scaleY(0); } to { transform: scaleY(1); } } +@keyframes card-stamp { + from { clip-path: inset(0 0 100% 0); transform: translateY(14px); } + to { clip-path: inset(0 0 0 0); transform: none; } +} +@keyframes bar-fill { from { transform: scaleX(0); } } + /* ---- Reduced motion --------------------------------------- */ @media (prefers-reduced-motion: reduce) { .brutalist-root *, .brutalist-root *::before, .brutalist-root *::after { @@ -603,7 +672,14 @@ input, position: relative; overflow: hidden; } -.continent-fill { display: block; height: 100%; background: var(--accent); } +.continent-fill { + display: block; + height: 100%; + background: var(--accent); + transform: scaleX(var(--p)); + transform-origin: left; + transition: transform var(--dur-slow) var(--ease-out); +} .continent-count { font-family: var(--font-mono); font-size: 13px; @@ -996,7 +1072,7 @@ input, gap: var(--s-8); align-items: start; } -.timeline { list-style: none; padding: 0; margin: 0; } +.timeline { list-style: none; padding: 0 0 0 var(--s-5); margin: 0; position: relative; } .timeline-row { display: grid; grid-template-columns: 140px 1fr 1.4fr; diff --git a/components/landing/BrutalistLanding.tsx b/components/landing/BrutalistLanding.tsx index 4f0b4b8c..e0f76ed3 100644 --- a/components/landing/BrutalistLanding.tsx +++ b/components/landing/BrutalistLanding.tsx @@ -68,6 +68,7 @@ export function BrutalistLanding({ travel }: { travel: LandingTravelData }) { return (
+
-
    - {bars.map((c) => ( +
      + {bars.map((c, i) => (
    • {c.name} - + + + + - {c.count}
    • ))}
    diff --git a/components/landing/useInView.ts b/components/landing/useInView.ts new file mode 100644 index 00000000..cdb5edba --- /dev/null +++ b/components/landing/useInView.ts @@ -0,0 +1,28 @@ +'use client' + +// useInView — flips `inView` to true once, the first time the element nears the +// viewport. One-shot by design: scroll-back never replays the entrance. +import { useEffect, useRef, useState } from 'react' + +export function useInView() { + const ref = useRef(null) + const [inView, setInView] = useState(false) + + useEffect(() => { + const el = ref.current + if (!el) return + const io = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return + setInView(true) + io.disconnect() + }, + // Inset the bottom edge so tall elements trigger before they're half read. + { rootMargin: '0px 0px -10% 0px' } + ) + io.observe(el) + return () => io.disconnect() + }, []) + + return { ref, inView } +} From a10830a176f75b891f6e695ee62f4d3e1d4e2366 Mon Sep 17 00:00:00 2001 From: dommango-sys <251805093+dommango@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:54:36 -0400 Subject: [PATCH 2/2] feat: draw flight routes in when the globe scrolls into view Routes trace origin to destination with a capped stagger, airports pop in after, and the globe drifts into place. Intro classes drop away afterwards so routes rotating into view later render solid, and any drag or key press cancels the spin. Reduced motion skips all of it. Co-Authored-By: Claude Sonnet 5.5 --- app/globals.css | 21 +++++++++++++ components/landing/Travel.tsx | 4 ++- components/travel/TravelMap.tsx | 42 +++++++++++++++++++++++-- components/travel/useGlobeIntro.ts | 50 ++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 components/travel/useGlobeIntro.ts diff --git a/app/globals.css b/app/globals.css index 471299d1..69341b60 100644 --- a/app/globals.css +++ b/app/globals.css @@ -345,7 +345,28 @@ input, animation: bar-fill var(--dur-slow) var(--ease-out) backwards; animation-delay: calc(var(--i, 0) * 60ms); } + + /* Flight globe intro. Timings mirror useGlobeIntro.ts (DRAW_MS = 900). Routes + carry pathLength=1, so dash units are fractions of each route's length. + The classes drop away after the intro, so routes that rotate into view + later render solid instead of redrawing. */ + .flight-line.is-pending { stroke-dasharray: 1; stroke-dashoffset: 1; } + .flight-line.is-drawing { + animation: route-draw 900ms var(--ease-out) backwards; + animation-delay: var(--delay, 0ms); + } + .airport-dot { transform-box: fill-box; transform-origin: center; } + .airport-dot.is-pending { transform: scale(0); } + .airport-dot.is-drawing { + animation: airport-pop var(--dur-base) var(--ease-out) backwards; + animation-delay: 900ms; + } +} +@keyframes route-draw { + from { stroke-dasharray: 1; stroke-dashoffset: 1; } + to { stroke-dasharray: 1; stroke-dashoffset: 0; } } +@keyframes airport-pop { from { transform: scale(0); } to { transform: scale(1); } } @keyframes scroll-progress { to { transform: scaleX(1); } } @keyframes rule-wipe { from { clip-path: inset(0 100% 0 0); color: var(--accent-text); } diff --git a/components/landing/Travel.tsx b/components/landing/Travel.tsx index df3b51cf..3134ae23 100644 --- a/components/landing/Travel.tsx +++ b/components/landing/Travel.tsx @@ -49,6 +49,7 @@ export function Travel({ const [year, setYear] = useState(max) const [playing, setPlaying] = useState(false) const { ref: listRef, inView: listInView } = useInView() + const { ref: mapRef, inView: mapInView } = useInView() const bars = buildContinentBars(countries, year) const visible = countriesUpTo(countries, year) @@ -162,12 +163,13 @@ export function Travel({ Flight history
-
+

diff --git a/components/travel/TravelMap.tsx b/components/travel/TravelMap.tsx index 9780b307..261f312f 100644 --- a/components/travel/TravelMap.tsx +++ b/components/travel/TravelMap.tsx @@ -11,6 +11,13 @@ import { } from "react-simple-maps"; import { geoOrthographic } from "d3-geo"; import { Country, FlightRoute } from "@/lib/content/travel"; +import { + INTRO_START_LON, + MAX_STAGGER_MS, + prefersReducedMotion, + useGlobeSpin, + useIntroDone, +} from "./useGlobeIntro"; // Served locally so a CDN hiccup can't blank the globe — the site otherwise // has no runtime third-party dependency. @@ -81,6 +88,8 @@ interface TravelMapProps { flightRoutes?: FlightRoute[]; selectedYear?: number; showFlights?: boolean; + /** Flips true once the globe scrolls into view; starts the route draw-in. */ + drawIn?: boolean; } interface TooltipData { @@ -104,12 +113,30 @@ export function TravelMap({ flightRoutes = [], selectedYear, showFlights = false, + drawIn = false, }: TravelMapProps) { const [tooltip, setTooltip] = useState(null); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + // Starts off-axis so the intro spin has somewhere to settle from; the map is + // client-only (dynamic, ssr:false), so reading matchMedia here is safe. const [rotation, setRotation] = useState<[number, number, number]>([ - 0, -20, 0, + prefersReducedMotion() ? 0 : INTRO_START_LON, + -20, + 0, ]); + const userRotated = useRef(false); + const introDone = useIntroDone(drawIn); + useGlobeSpin(drawIn, setRotation, userRotated); + const routeClass = !drawIn + ? "flight-line is-pending" + : introDone + ? "flight-line" + : "flight-line is-drawing"; + const airportClass = !drawIn + ? "airport-dot is-pending" + : introDone + ? "airport-dot" + : "airport-dot is-drawing"; const [isDragging, setIsDragging] = useState(false); const dragStart = useRef<{ x: number; @@ -183,6 +210,7 @@ export function TravelMap({ const handleMouseDown = useCallback( (e: React.PointerEvent) => { + userRotated.current = true; setIsDragging(true); dragStart.current = { x: e.clientX, @@ -259,6 +287,7 @@ export function TravelMap({ aria-label={`Globe showing ${displayedCountries.length} visited countries. Drag or use the arrow keys to rotate.`} onKeyDown={(e) => { const step = 10; + userRotated.current = true; const [lon, lat] = rotation; // Match drag: dragging up increases rotation[1] (dy < 0, so // rotation[1] - dy*sensitivity grows), so ArrowUp must also increase it. @@ -354,7 +383,15 @@ export function TravelMap({ strokeOpacity={0.6} strokeWidth={Math.min(0.8 + route.count * 0.15, 3)} strokeLinecap="round" - style={{ cursor: "pointer" }} + // Normalises dash lengths so the draw-in works for any route length. + pathLength={1} + className={routeClass} + style={ + { + cursor: "pointer", + "--delay": `${Math.min(idx * 25, MAX_STAGGER_MS)}ms`, + } as React.CSSProperties + } onMouseEnter={() => showFlightTooltip(route.from, route.to, route.count) } @@ -382,6 +419,7 @@ export function TravelMap({ r={Math.min(2.5 + airport.count * 0.05, 5)} stroke="var(--map-stroke)" strokeWidth={0.5} + className={airportClass} style={{ fill: "var(--accent)", cursor: "pointer" }} /> diff --git a/components/travel/useGlobeIntro.ts b/components/travel/useGlobeIntro.ts new file mode 100644 index 00000000..824b59fd --- /dev/null +++ b/components/travel/useGlobeIntro.ts @@ -0,0 +1,50 @@ +'use client' + +// Intro choreography for the flight globe: the globe drifts into place while the +// routes draw in (CSS, see "Flight globe intro" in globals.css). Timings here +// must stay in step with the --draw-ms keyframe duration there. +import { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react' + +type Rotation = [number, number, number] + +export const INTRO_START_LON = -50 +export const DRAW_MS = 900 +export const MAX_STAGGER_MS = 1200 +const SPIN_MS = 1400 +const INTRO_TOTAL_MS = MAX_STAGGER_MS + DRAW_MS + 300 + +export const prefersReducedMotion = () => + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + +/** True once the intro window after `active` has elapsed. Stays true afterwards. */ +export function useIntroDone(active: boolean) { + const [done, setDone] = useState(false) + useEffect(() => { + if (!active) return + const id = setTimeout(() => setDone(true), INTRO_TOTAL_MS) + return () => clearTimeout(id) + }, [active]) + return done +} + +/** Eases longitude from INTRO_START_LON to 0 once `active`; yields to any user input. */ +export function useGlobeSpin( + active: boolean, + setRotation: Dispatch>, + interrupted: RefObject +) { + useEffect(() => { + if (!active || prefersReducedMotion()) return + const start = performance.now() + let raf = 0 + const step = (now: number) => { + if (interrupted.current) return + const t = Math.min(1, (now - start) / SPIN_MS) + const eased = 1 - Math.pow(1 - t, 3) + setRotation(([, lat]) => [INTRO_START_LON * (1 - eased), lat, 0]) + if (t < 1) raf = requestAnimationFrame(step) + } + raf = requestAnimationFrame(step) + return () => cancelAnimationFrame(raf) + }, [active, setRotation, interrupted]) +}