Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 99 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,96 @@ 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);
}

/* 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); }
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 {
Expand Down Expand Up @@ -603,7 +693,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;
Expand Down Expand Up @@ -996,7 +1093,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;
Expand Down
1 change: 1 addition & 0 deletions components/landing/BrutalistLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function BrutalistLanding({ travel }: { travel: LandingTravelData }) {

return (
<div className="brutalist-root" data-accent={accentAttr} data-contrast={contrastAttr}>
<div className="scroll-progress" aria-hidden="true" />
<div className="page">
<a className="skip-link" href="#projects">Skip to projects</a>
<Nav
Expand Down
30 changes: 30 additions & 0 deletions components/landing/CountUp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client'

// CountUp — ticks 0 → value once when `active` first turns true, then shows
// `value` directly so the year scrubber stays instant afterwards. Shows the real
// value until activated, so server HTML and no-JS visitors see the number, not 0.
import { useEffect, useState } from 'react'

const DURATION_MS = 700

export function CountUp({ value, active }: { value: number; active: boolean }) {
const [progress, setProgress] = useState(0)
const [done, setDone] = useState(false)

useEffect(() => {
if (!active) return
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const start = performance.now()
let raf = 0
const step = (now: number) => {
const t = reduced ? 1 : Math.min(1, (now - start) / DURATION_MS)
setProgress(1 - Math.pow(1 - t, 3))
if (t < 1) raf = requestAnimationFrame(step)
else setDone(true)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [active])

return <>{active && !done ? Math.round(value * progress) : value}</>
}
15 changes: 11 additions & 4 deletions components/landing/Projects.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Projects — the main section. Catalog-ID cards for shipped software.
import type { CSSProperties } from 'react'
import { PROJECTS, type Project } from '@/lib/content/projects'
import { BinaryRule } from './BinaryRule'
import { RevealGroup } from './RevealGroup'

const LINK_LABEL: Record<NonNullable<Project['hrefKind']>, string> = {
live: 'Open live ↗',
Expand Down Expand Up @@ -40,25 +42,30 @@ export function Projects() {
</p>
</div>

<div className="projects-grid">
{PROJECTS.map((project) =>
<RevealGroup className="projects-grid">
{PROJECTS.map((project, i) =>
project.href ? (
<a
key={project.id}
className="work-card is-linked"
style={{ '--i': i } as CSSProperties}
href={project.href}
target="_blank"
rel="noreferrer"
>
<CardBody project={project} />
</a>
) : (
<article key={project.id} className="work-card">
<article
key={project.id}
className="work-card"
style={{ '--i': i } as CSSProperties}
>
<CardBody project={project} />
</article>
)
)}
</div>
</RevealGroup>
</section>
)
}
16 changes: 16 additions & 0 deletions components/landing/RevealGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client'

// RevealGroup — a wrapper that adds `is-in` once it scrolls into view. Children
// stagger themselves in CSS via their own `--i` index. Lets server components
// (Projects) opt into the entrance without becoming client components.
import type { ReactNode } from 'react'
import { useInView } from './useInView'

export function RevealGroup({ className, children }: { className: string; children: ReactNode }) {
const { ref, inView } = useInView<HTMLDivElement>()
return (
<div ref={ref} className={`${className}${inView ? ' is-in' : ''}`}>
{children}
</div>
)
}
22 changes: 16 additions & 6 deletions components/landing/Travel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

// Travel — brutalist continent bars + the real interactive globe map.
import { useEffect, useState } from 'react'
import { useEffect, useState, type CSSProperties } from 'react'
import dynamic from 'next/dynamic'
import {
buildContinentBars,
Expand All @@ -12,6 +12,8 @@ import {
type FlightRoute,
} from '@/lib/content/travel'
import { BinaryRule } from './BinaryRule'
import { CountUp } from './CountUp'
import { useInView } from './useInView'

const TravelMap = dynamic(
() => import('@/components/travel/TravelMap').then((mod) => mod.TravelMap),
Expand Down Expand Up @@ -46,6 +48,8 @@ export function Travel({
const [min, max] = yearBounds(countries)
const [year, setYear] = useState(max)
const [playing, setPlaying] = useState(false)
const { ref: listRef, inView: listInView } = useInView<HTMLUListElement>()
const { ref: mapRef, inView: mapInView } = useInView<HTMLDivElement>()

const bars = buildContinentBars(countries, year)
const visible = countriesUpTo(countries, year)
Expand Down Expand Up @@ -82,14 +86,19 @@ export function Travel({
</div>

<div className="travel-grid">
<ul className="continent-list">
{bars.map((c) => (
<ul ref={listRef} className={`continent-list${listInView ? ' is-in' : ''}`}>
{bars.map((c, i) => (
<li key={c.name} className="continent-row">
<span className="continent-name">{c.name}</span>
<span className="continent-bar">
<span className="continent-fill" style={{ width: `${c.pct}%` }} />
<span
className="continent-fill"
style={{ '--p': c.pct / 100, '--i': i } as CSSProperties}
/>
</span>
<span className="continent-count">
<CountUp value={c.count} active={listInView} />
</span>
<span className="continent-count">{c.count}</span>
</li>
))}
</ul>
Expand Down Expand Up @@ -154,12 +163,13 @@ export function Travel({
Flight history
</button>
</div>
<div className="travel-map-frame">
<div className="travel-map-frame" ref={mapRef}>
<TravelMap
countries={countries}
flightRoutes={flightRoutes}
showFlights={showFlights}
selectedYear={year}
drawIn={mapInView}
/>
</div>
<p className="travel-map-cap">
Expand Down
28 changes: 28 additions & 0 deletions components/landing/useInView.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Element>() {
const ref = useRef<T>(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 }
}
Loading
Loading