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:
Nicholai 2025-12-12 14:23:40 -07:00
parent 43d52e452d
commit 17c2a3603f
6 changed files with 274 additions and 107 deletions

View File

@ -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} />

View File

@ -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);
};
}, []);

View File

@ -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) {
const now = new Date();
const timeString = now.toLocaleTimeString('en-US', {hour12: false, timeZone: 'America/Denver'});
clock.textContent = timeString + " MST";
}
if (!clock) return;
const now = new Date();
const timeString = now.toLocaleTimeString('en-US', { hour12: false, timeZone: 'America/Denver' });
clock.textContent = `${timeString} MST`;
}
setInterval(updateClock, 1000);
updateClock();
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', () => {
@ -138,21 +162,23 @@ const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bi
portrait.classList.add('portrait-visible');
}
// Trigger Grid Ripple
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
// 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
setTimeout(() => {
cell.classList.add('active');
setTimeout(() => {
cell.classList.remove('active');
}, 200);
}, delay);
});
window.setTimeout(() => {
cell.classList.add('active');
window.setTimeout(() => {
cell.classList.remove('active');
}, 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) {
const index = row * 10 + col;
const cell = cells[index];
if (col < 0 || col >= 10 || row < 0 || row >= 10) return;
if (cell) {
// Remove active class from all others (optional, or let them fade)
// cell.classList.add('active');
const index = row * 10 + col;
if (index === lastIndex) return;
lastIndex = index;
// To prevent "sticking", we can use a timestamp or just let CSS transition handle fade out
// But we need to trigger the "hit"
const cell = cells[index] as HTMLElement | undefined;
if (!cell) return;
// Logic: Add active, then remove it shortly after to trigger fade
cell.classList.add('active');
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.
// Shorter hold time for a quicker trail.
timeouts[index] = window.setTimeout(() => {
cell.classList.remove('active');
timeouts[index] = 0;
}, 35);
};
if (cell.dataset.timeout) {
clearTimeout(Number(cell.dataset.timeout));
}
const timeoutId = setTimeout(() => {
cell.classList.remove('active');
}, 50); // Short "hold" time, then fade out via CSS
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)
const randomIndex = Math.floor(Math.random() * cells.length);
const cell = cells[randomIndex];
if(cell) {
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');
setTimeout(() => {
window.setTimeout(() => {
cell.classList.remove('active');
}, 200);
}
}, 160);
}, 1200);
}
setInterval(pulseRandomSquare, 1500);
startPulse();
document.addEventListener('visibilitychange', () => {
// Keep timers light in background.
if (!document.hidden) {
updateClockOnce();
}
});
</script>

View File

@ -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,17 +88,93 @@ const {
});
}, revealObserverOptions);
document.querySelectorAll('.reveal-text').forEach(el => {
revealObserver.observe(el);
});
// Auto-stagger children in containers with .stagger-children class
document.querySelectorAll('.stagger-children').forEach(container => {
const children = container.querySelectorAll('.animate-on-scroll');
children.forEach((child, index) => {
child.classList.add(`stagger-${Math.min(index + 1, 8)}`);
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);
});
// Auto-stagger children in containers with .stagger-children class
document.querySelectorAll('.stagger-children').forEach(container => {
const children = container.querySelectorAll('.animate-on-scroll');
children.forEach((child, index) => {
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>

View File

@ -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');

View File

@ -234,6 +234,7 @@ html {
border-radius: 50%;
z-index: 9999;
pointer-events: none;
will-change: transform;
}
.cursor-dot {