Nicholai 17c2a3603f Refactor components for performance and accessibility improvements
- Removed unused Lucide CDN script from BaseHead component to reduce network costs.
- Enhanced CustomCursor component with requestAnimationFrame for smoother animations and respect for user motion preferences.
- Updated Hero section styles for faster fade-out transitions and improved responsiveness to user preferences.
- Optimized clock functionality in Hero section to reduce drift and improve performance.
- Streamlined mousemove event handling in Hero section for better performance and reduced resource usage.
- Lazy-loaded markdown renderer in contact page to keep initial JavaScript lighter.
- Added will-change property to global CSS for improved rendering performance.
2025-12-12 14:23:40 -07:00

272 lines
9.9 KiB
Plaintext

---
import { Picture } from 'astro:assets';
import heroPortrait from '../../assets/nicholai-closeup-portrait.JPEG';
interface Props {
headlineLine1: string;
headlineLine2: string;
portfolioYear: string;
location: string;
locationLabel: string;
bio: string;
}
const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bio } = Astro.props;
---
<section id="hero" class="relative w-full h-[100dvh] overflow-hidden bg-brand-dark">
<!-- Background Image (Portrait) - Optimized with AVIF/WebP -->
<div class="absolute top-0 right-0 w-full md:w-1/2 h-full z-0">
<div class="relative w-full h-full">
<Picture
src={heroPortrait}
formats={['avif', 'webp']}
widths={[640, 1024, 1600]}
sizes="(max-width: 768px) 100vw, 50vw"
alt="Nicholai Vogel portrait"
class="w-full h-full object-cover object-center opacity-0 mix-blend-luminosity md:opacity-0 transition-opacity duration-[2000ms] ease-out delay-500 intro-element"
id="hero-portrait"
loading="eager"
decoding="sync"
/>
<div class="absolute inset-0 bg-gradient-to-l from-transparent via-brand-dark/50 to-brand-dark"></div>
<div class="absolute inset-0 bg-gradient-to-t from-brand-dark via-transparent to-transparent"></div>
</div>
</div>
<!-- The 100 Squares Grid Overlay -->
<div id="grid-container" class="absolute inset-0 z-10 w-full h-full grid grid-cols-10 grid-rows-10 pointer-events-none">
{Array.from({ length: 100 }).map((_, i) => (
<div class="grid-cell w-full h-full border border-white/5 opacity-0 transition-all duration-500 ease-out" data-index={i}></div>
))}
</div>
<!-- The Content -->
<!-- Adjusted pt to clear fixed nav since BaseLayout padding is removed -->
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-32 lg:pt-40 pointer-events-auto">
<!-- Top Metadata -->
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">
<div class="font-mono text-xs uppercase tracking-widest text-slate-500">
{portfolioYear}
</div>
<div class="font-mono text-xs text-slate-500 text-right tracking-wide">
<span class="block text-slate-600 mb-1 uppercase tracking-widest">{locationLabel}</span>
{location}<br>
<span id="clock" class="text-brand-accent">00:00:00 MST</span>
</div>
</div>
<!-- Main Heading & Description -->
<div class="max-w-5xl">
<h1 class="text-6xl md:text-8xl lg:text-9xl tracking-tighter leading-[0.9] font-bold text-white mix-blend-overlay opacity-90 mb-8 perspective-text">
<span class="block intro-element opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-100">{headlineLine1}</span>
<span class="block text-brand-accent opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-200 intro-element">{headlineLine2}</span>
</h1>
<p class="font-mono text-sm md:text-base max-w-lg text-slate-400 font-light leading-relaxed intro-element opacity-0 translate-y-6 transition-all duration-1000 ease-out delay-500">
{bio}
</p>
</div>
<!-- Bottom Navigation -->
<div class="flex justify-between items-end w-full intro-element opacity-0 transition-all duration-1000 ease-out delay-700">
<a href="#experience" class="flex items-center justify-center w-12 h-12 border border-white/10 rounded-full text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="group-hover:animate-bounce">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
</a>
<div class="text-right font-mono text-xs text-slate-500 tracking-widest">
SCROLL TO EXPLORE
</div>
</div>
</div>
</section>
<style>
.grid-cell.active {
background-color: var(--color-brand-accent);
opacity: 0.15;
transition: opacity 0s, background-color 0s; /* Instant on */
}
/* Fade out */
.grid-cell {
/* Slightly faster fade-out for a snappier feel */
transition: opacity 0.6s ease-out, background-color 0.6s ease-out;
}
/* Initial Loaded State Classes */
.intro-visible {
opacity: 1 !important;
transform: translateY(0) !important;
}
/* Portrait Loaded State */
.portrait-visible {
opacity: 0.4 !important; /* Mobile default */
}
@media (min-width: 768px) {
.portrait-visible {
opacity: 0.6 !important; /* Desktop default */
}
}
</style>
<script>
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
const finePointer = window.matchMedia?.('(pointer: fine) and (hover: hover)')?.matches ?? false;
// ===== CLOCK (pause on hidden tab, align to second boundaries) =====
let clockTimer = 0;
function updateClockOnce() {
const clock = document.getElementById('clock');
if (!clock) return;
const now = new Date();
const timeString = now.toLocaleTimeString('en-US', { hour12: false, timeZone: 'America/Denver' });
clock.textContent = `${timeString} MST`;
}
function startClock() {
if (clockTimer) window.clearTimeout(clockTimer);
const tick = () => {
if (document.hidden) {
clockTimer = window.setTimeout(tick, 1000);
return;
}
updateClockOnce();
// Align to the next second boundary to reduce drift.
const msToNextSecond = 1000 - (Date.now() % 1000);
clockTimer = window.setTimeout(tick, msToNextSecond);
};
tick();
}
startClock();
// Intro Animation Sequence
window.addEventListener('load', () => {
// Trigger Intro Elements
const introElements = document.querySelectorAll('.intro-element');
introElements.forEach(el => {
el.classList.add('intro-visible');
});
// Trigger Portrait
const portrait = document.getElementById('hero-portrait');
if (portrait) {
portrait.classList.add('portrait-visible');
}
// Trigger Grid Ripple (skip if reduced motion)
if (!reduceMotion) {
const cells = document.querySelectorAll('.grid-cell');
// Diagonal sweep effect
cells.forEach((cell, i) => {
const row = Math.floor(i / 10);
const col = i % 10;
const delay = (row + col) * 45; // slightly faster diagonal delay
window.setTimeout(() => {
cell.classList.add('active');
window.setTimeout(() => {
cell.classList.remove('active');
}, 180);
}, delay);
});
}
});
// Robust Grid Interaction
const section = document.getElementById('hero');
const cells = document.querySelectorAll('.grid-cell');
if (section) {
// Throttle mousemove work to one update per frame.
let latestX = 0;
let latestY = 0;
let pending = false;
let lastIndex = -1;
const timeouts: number[] = new Array(cells.length).fill(0);
const process = () => {
pending = false;
if (!finePointer || reduceMotion) return;
const rect = section.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
if (width <= 0 || height <= 0) return;
const x = latestX - rect.left;
const y = latestY - rect.top;
const col = Math.floor((x / width) * 10);
const row = Math.floor((y / height) * 10);
if (col < 0 || col >= 10 || row < 0 || row >= 10) return;
const index = row * 10 + col;
if (index === lastIndex) return;
lastIndex = index;
const cell = cells[index] as HTMLElement | undefined;
if (!cell) return;
cell.classList.add('active');
const prev = timeouts[index];
if (prev) window.clearTimeout(prev);
// Shorter hold time for a quicker trail.
timeouts[index] = window.setTimeout(() => {
cell.classList.remove('active');
timeouts[index] = 0;
}, 35);
};
section.addEventListener('mousemove', (e) => {
latestX = e.clientX;
latestY = e.clientY;
if (pending) return;
pending = true;
window.requestAnimationFrame(process);
}, { passive: true });
}
// Random pulse for liveliness
let pulseInterval = 0;
function startPulse() {
if (pulseInterval) window.clearInterval(pulseInterval);
if (!finePointer || reduceMotion) return;
pulseInterval = window.setInterval(() => {
if (document.hidden) return;
const randomIndex = Math.floor(Math.random() * cells.length);
const cell = cells[randomIndex] as HTMLElement | undefined;
if (!cell) return;
cell.classList.add('active');
window.setTimeout(() => {
cell.classList.remove('active');
}, 160);
}, 1200);
}
startPulse();
document.addEventListener('visibilitychange', () => {
// Keep timers light in background.
if (!document.hidden) {
updateClockOnce();
}
});
</script>