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.
This commit is contained in:
parent
43d52e452d
commit
17c2a3603f
@ -137,7 +137,7 @@ const professionalServiceSchema = {
|
||||
</noscript>
|
||||
|
||||
<!-- Icons - Load async to prevent render blocking -->
|
||||
<script src="https://unpkg.com/lucide@latest" defer></script>
|
||||
<!-- (Removed) Lucide CDN script: currently unused in this repo and adds a global network+JS cost. -->
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
@ -10,25 +10,78 @@ const CustomCursor = () => {
|
||||
|
||||
if (!dot || !outline) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const posX = e.clientX;
|
||||
const posY = e.clientY;
|
||||
// Respect user preferences
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
|
||||
if (reduceMotion) return;
|
||||
|
||||
// Dot follows instantly
|
||||
dot.style.left = `${posX}px`;
|
||||
dot.style.top = `${posY}px`;
|
||||
// rAF-driven cursor animation (single loop, no per-event Web Animations allocations)
|
||||
let targetX = 0;
|
||||
let targetY = 0;
|
||||
let dotX = 0;
|
||||
let dotY = 0;
|
||||
let outlineX = 0;
|
||||
let outlineY = 0;
|
||||
|
||||
// Outline follows with animation
|
||||
outline.animate({
|
||||
left: `${posX}px`,
|
||||
top: `${posY}px`
|
||||
}, { duration: 500, fill: "forwards" });
|
||||
let hasInit = false;
|
||||
let rafId: number | null = null;
|
||||
let lastMoveTs = performance.now();
|
||||
|
||||
const DOT_LERP = 0.65; // tighter = closer to 1
|
||||
const OUTLINE_LERP = 0.18; // tighter = closer to 1
|
||||
const STOP_AFTER_MS = 140;
|
||||
const STOP_EPS_PX = 0.35;
|
||||
|
||||
const applyTransforms = () => {
|
||||
dot.style.transform = `translate3d(${dotX}px, ${dotY}px, 0) translate(-50%, -50%)`;
|
||||
outline.style.transform = `translate3d(${outlineX}px, ${outlineY}px, 0) translate(-50%, -50%)`;
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
const tick = (ts: number) => {
|
||||
// Lerp towards the target
|
||||
dotX += (targetX - dotX) * DOT_LERP;
|
||||
dotY += (targetY - dotY) * DOT_LERP;
|
||||
outlineX += (targetX - outlineX) * OUTLINE_LERP;
|
||||
outlineY += (targetY - outlineY) * OUTLINE_LERP;
|
||||
|
||||
applyTransforms();
|
||||
|
||||
const idle = ts - lastMoveTs > STOP_AFTER_MS;
|
||||
const dx = Math.abs(targetX - outlineX);
|
||||
const dy = Math.abs(targetY - outlineY);
|
||||
const settled = dx < STOP_EPS_PX && dy < STOP_EPS_PX;
|
||||
|
||||
if (idle && settled) {
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
targetX = e.clientX;
|
||||
targetY = e.clientY;
|
||||
lastMoveTs = performance.now();
|
||||
|
||||
if (!hasInit) {
|
||||
hasInit = true;
|
||||
dotX = targetX;
|
||||
dotY = targetY;
|
||||
outlineX = targetX;
|
||||
outlineY = targetY;
|
||||
applyTransforms();
|
||||
}
|
||||
|
||||
if (rafId === null) {
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
if (rafId !== null) window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -91,7 +91,8 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
|
||||
}
|
||||
/* Fade out */
|
||||
.grid-cell {
|
||||
transition: opacity 0.8s ease-out, background-color 0.8s ease-out;
|
||||
/* Slightly faster fade-out for a snappier feel */
|
||||
transition: opacity 0.6s ease-out, background-color 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* Initial Loaded State Classes */
|
||||
@ -112,17 +113,40 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Clock
|
||||
function updateClock() {
|
||||
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) {
|
||||
if (!clock) return;
|
||||
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('en-US', {hour12: false, timeZone: 'America/Denver'});
|
||||
clock.textContent = timeString + " MST";
|
||||
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;
|
||||
}
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
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', () => {
|
||||
@ -138,21 +162,23 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
|
||||
portrait.classList.add('portrait-visible');
|
||||
}
|
||||
|
||||
// Trigger Grid Ripple
|
||||
// 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) * 50; // Diagonal delay
|
||||
const delay = (row + col) * 45; // slightly faster diagonal delay
|
||||
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
cell.classList.add('active');
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
cell.classList.remove('active');
|
||||
}, 200);
|
||||
}, 180);
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -161,67 +187,85 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
|
||||
const cells = document.querySelectorAll('.grid-cell');
|
||||
|
||||
if (section) {
|
||||
section.addEventListener('mousemove', (e) => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
// Calculate relative coordinates
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
// 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);
|
||||
|
||||
// Calculate grid dimensions
|
||||
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;
|
||||
|
||||
// 10x10 grid
|
||||
const col = Math.floor((x / width) * 10);
|
||||
const row = Math.floor((y / height) * 10);
|
||||
|
||||
// Bounds check
|
||||
if (col >= 0 && col < 10 && row >= 0 && row < 10) {
|
||||
if (col < 0 || col >= 10 || row < 0 || row >= 10) return;
|
||||
|
||||
const index = row * 10 + col;
|
||||
const cell = cells[index];
|
||||
if (index === lastIndex) return;
|
||||
lastIndex = index;
|
||||
|
||||
if (cell) {
|
||||
// Remove active class from all others (optional, or let them fade)
|
||||
// cell.classList.add('active');
|
||||
const cell = cells[index] as HTMLElement | undefined;
|
||||
if (!cell) return;
|
||||
|
||||
// To prevent "sticking", we can use a timestamp or just let CSS transition handle fade out
|
||||
// But we need to trigger the "hit"
|
||||
|
||||
// Logic: Add active, then remove it shortly after to trigger fade
|
||||
cell.classList.add('active');
|
||||
|
||||
// Clear previous timeout if this cell was just hit?
|
||||
// Simpler: Just add active. Use requestAnimationFrame to clear "old" active cells?
|
||||
// Or just let the CSS transition handle the fade out when class is removed.
|
||||
const prev = timeouts[index];
|
||||
if (prev) window.clearTimeout(prev);
|
||||
|
||||
// Better approach for "trail":
|
||||
// Add active class. Set a timeout to remove it.
|
||||
|
||||
if (cell.dataset.timeout) {
|
||||
clearTimeout(Number(cell.dataset.timeout));
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Shorter hold time for a quicker trail.
|
||||
timeouts[index] = window.setTimeout(() => {
|
||||
cell.classList.remove('active');
|
||||
}, 50); // Short "hold" time, then fade out via CSS
|
||||
timeouts[index] = 0;
|
||||
}, 35);
|
||||
};
|
||||
|
||||
cell.dataset.timeout = String(timeoutId);
|
||||
}
|
||||
}
|
||||
});
|
||||
section.addEventListener('mousemove', (e) => {
|
||||
latestX = e.clientX;
|
||||
latestY = e.clientY;
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
window.requestAnimationFrame(process);
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Random pulse for liveliness
|
||||
function pulseRandomSquare() {
|
||||
// Only pulse if not currently interacting (optional, but keeps it cleaner)
|
||||
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];
|
||||
if(cell) {
|
||||
const cell = cells[randomIndex] as HTMLElement | undefined;
|
||||
if (!cell) return;
|
||||
|
||||
cell.classList.add('active');
|
||||
setTimeout(() => {
|
||||
window.setTimeout(() => {
|
||||
cell.classList.remove('active');
|
||||
}, 200);
|
||||
}, 160);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
startPulse();
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
// Keep timers light in background.
|
||||
if (!document.hidden) {
|
||||
updateClockOnce();
|
||||
}
|
||||
setInterval(pulseRandomSquare, 1500);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -42,7 +42,8 @@ const {
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark bg-brand-dark text-white">
|
||||
<CustomCursor client:load />
|
||||
<!-- Only hydrate custom cursor on devices that can actually benefit from it -->
|
||||
<CustomCursor client:media="(pointer: fine) and (hover: hover)" />
|
||||
<GridOverlay />
|
||||
<Navigation />
|
||||
|
||||
@ -53,14 +54,9 @@ const {
|
||||
<Footer />
|
||||
|
||||
<script>
|
||||
// Initialize Lucide icons
|
||||
// @ts-ignore
|
||||
if (window.lucide) {
|
||||
// @ts-ignore
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
|
||||
// ===== SCROLL ANIMATION SYSTEM =====
|
||||
// If you're using Astro view transitions, elements can change between navigations.
|
||||
// We'll (re)bind observers on initial load and on `astro:page-load`.
|
||||
|
||||
// Observer for scroll-triggered animations
|
||||
const scrollObserverOptions = {
|
||||
@ -78,11 +74,6 @@ const {
|
||||
});
|
||||
}, scrollObserverOptions);
|
||||
|
||||
// Observe all animate-on-scroll elements
|
||||
document.querySelectorAll('.animate-on-scroll').forEach(el => {
|
||||
scrollObserver.observe(el);
|
||||
});
|
||||
|
||||
// Observer for legacy reveal-text animations
|
||||
const revealObserverOptions = {
|
||||
threshold: 0.1,
|
||||
@ -97,7 +88,14 @@ const {
|
||||
});
|
||||
}, revealObserverOptions);
|
||||
|
||||
document.querySelectorAll('.reveal-text').forEach(el => {
|
||||
function bindScrollAnimations() {
|
||||
// Observe animate-on-scroll elements (avoid re-observing elements already visible)
|
||||
document.querySelectorAll('.animate-on-scroll:not(.is-visible)').forEach(el => {
|
||||
scrollObserver.observe(el);
|
||||
});
|
||||
|
||||
// Observe reveal-text elements
|
||||
document.querySelectorAll('.reveal-text:not(.active)').forEach(el => {
|
||||
revealObserver.observe(el);
|
||||
});
|
||||
|
||||
@ -108,6 +106,75 @@ const {
|
||||
child.classList.add(`stagger-${Math.min(index + 1, 8)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial bind
|
||||
bindScrollAnimations();
|
||||
|
||||
// Re-bind on Astro page transitions (if enabled)
|
||||
document.addEventListener('astro:page-load', bindScrollAnimations);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ===== INTENT-BASED PREFETCH (hover/focus) =====
|
||||
// Lightweight prefetch to make navigation feel instant without a full SPA router.
|
||||
const prefetched = new Set();
|
||||
|
||||
function isPrefetchableUrl(url) {
|
||||
try {
|
||||
const u = new URL(url, window.location.href);
|
||||
if (u.origin !== window.location.origin) return false;
|
||||
if (u.hash) return false;
|
||||
if (u.pathname === window.location.pathname && u.search === window.location.search) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function prefetchDocument(url) {
|
||||
if (!isPrefetchableUrl(url)) return;
|
||||
const u = new URL(url, window.location.href);
|
||||
const key = u.href;
|
||||
if (prefetched.has(key)) return;
|
||||
prefetched.add(key);
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.as = 'document';
|
||||
link.href = key;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function getAnchorFromEventTarget(target) {
|
||||
if (!(target instanceof Element)) return null;
|
||||
return target.closest('a[href]');
|
||||
}
|
||||
|
||||
const schedule = (href) => {
|
||||
// Don't block input; prefetch when the browser is idle if possible.
|
||||
// @ts-ignore - requestIdleCallback isn't in all TS lib targets
|
||||
if (window.requestIdleCallback) {
|
||||
// @ts-ignore
|
||||
window.requestIdleCallback(() => prefetchDocument(href), { timeout: 1000 });
|
||||
} else {
|
||||
setTimeout(() => prefetchDocument(href), 0);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const a = getAnchorFromEventTarget(e.target);
|
||||
const href = a?.getAttribute('href');
|
||||
if (!href) return;
|
||||
schedule(href);
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('focusin', (e) => {
|
||||
const a = getAnchorFromEventTarget(e.target);
|
||||
const href = a?.getAttribute('href');
|
||||
if (!href) return;
|
||||
schedule(href);
|
||||
}, { passive: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -428,8 +428,6 @@ const contactContent = contactEntry.data;
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { marked } from 'marked';
|
||||
|
||||
// ===== Custom Dropdown Logic =====
|
||||
const selectContainer = document.getElementById('custom-select');
|
||||
const selectTrigger = document.getElementById('select-trigger');
|
||||
@ -657,6 +655,8 @@ const contactContent = contactEntry.data;
|
||||
// Success path - render markdown response
|
||||
if (data.success && data.format === 'mdx' && data.message) {
|
||||
try {
|
||||
// Lazy-load markdown renderer only when needed (keeps initial JS lighter)
|
||||
const { marked } = await import('marked');
|
||||
const htmlContent = await marked.parse(data.message);
|
||||
responseContent.innerHTML = htmlContent;
|
||||
|
||||
@ -669,7 +669,9 @@ const contactContent = contactEntry.data;
|
||||
|
||||
} catch (markdownError) {
|
||||
console.error('Markdown parsing error:', markdownError);
|
||||
throw new Error('Failed to render response');
|
||||
// Fallback: show plain text instead of failing the whole interaction.
|
||||
responseContent.textContent = String(data.message);
|
||||
showResponse();
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid response format from server');
|
||||
|
||||
@ -234,6 +234,7 @@ html {
|
||||
border-radius: 50%;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.cursor-dot {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user