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
56 changes: 56 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Personal portfolio website for Thomas J Butler — a React 19 + TypeScript SPA with a Matrix-themed aesthetic (green terminal effects, CRT overlays, particle backgrounds). Deployed to GitHub Pages at thomasjbutler.github.io.

## Commands

| Command | Purpose |
|---------|---------|
| `npm run dev` | Dev server on port 3000 (opens /react.html) |
| `npm run build` | Production build to dist/ |
| `npm run preview` | Preview production build |
| `npm run lint` | ESLint on src/**/*.{js,ts} |
| `npm run format` | Prettier on src/**/*.{js,ts,css,html} |
| `npm run type-check` | TypeScript check (no emit) |
| `npm run test` | Vitest |
| `npm run test:ui` | Vitest with UI |
| `npm run test:coverage` | Vitest with coverage |
| `npm run deploy` | Build + deploy to GitHub Pages via gh-pages |

## Architecture

**Stack:** React 19, TypeScript (strict), Vite 7, React Router v7 (BrowserRouter)

**Entry flow:** `index.html` redirects to `react.html`, which loads `src/main.tsx` -> `App.tsx`. A third entry `blog.html` exists for legacy blog URL compatibility.

**Routing:** All pages are lazy-loaded via `React.lazy()` + `Suspense` in `App.tsx`. Blog routes are currently commented out. Legacy `.html` routes redirect to clean paths. The `/skills` route redirects to `/services`.

**CSS architecture:** `src/css/main.css` is the master import file. Styles are organized into `base/`, `components/`, `pages/`, and `utilities/` subdirectories using partial files (prefixed with `_`). Theme variables live in `themes.css` with light/dark mode via CSS custom properties and React Context (`ThemeContext`).

**Animation libraries:** GSAP, Anime.js (v4), Framer Motion, AOS (Animate On Scroll), ScrollMagic. Matrix rain, CRT effects, and particle backgrounds are custom implementations.

**Path aliases:** `@/` -> `src/`, plus `@components/`, `@pages/`, `@hooks/`, `@utils/`, `@css/`, `@js/`, `@images/` (configured in both tsconfig.json and vite.config.mjs).

**Key directories:**
- `src/pages/` — Route-level page components
- `src/components/` — Reusable UI components
- `src/hooks/` — Custom hooks (scroll animation, lazy loading, SEO, performance)
- `src/utils/` — Utilities (keyboard nav, performance optimizer, animations)
- `src/contexts/` — React Context (ThemeContext for light/dark mode)
- `src/css/` — Organized stylesheet modules
- `src/content/blog/` — Markdown blog posts (copied to dist on build)

## Code Style

- Prettier: single quotes, semicolons, 100 char width, trailing commas (es5)
- ESLint: TypeScript strict, React hooks rules, no-var, no-require
- Components use named exports (not default), except where lazy loading requires `.then(m => ({ default: m.ComponentName }))`
- Global animation libs (anime, gsap, ScrollMagic, AOS) are declared as ESLint globals

## Deployment

GitHub Actions workflow (`.github/workflows/deploy.yml`) auto-deploys on push to main. Manual deploys via `npm run deploy` use the `gh-pages` package to push `dist/` to the gh-pages branch.
20 changes: 14 additions & 6 deletions src/components/CRTEffect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,28 @@ export const CRTEffect: React.FC<CRTEffectProps> = ({
* @listens flicker - Initialises random flicker intervals when enabled
*/
useEffect(() => {
if (!containerRef.current) return;
if (!containerRef.current || !flicker) return;

if (flicker) {
const flickerInterval = setInterval(() => {
let rafId: number;
let lastFlicker = 0;

const tick = (timestamp: number) => {
// Check roughly every 100ms (but via RAF, not setInterval)
if (timestamp - lastFlicker > 100) {
lastFlicker = timestamp;
if (Math.random() > 0.99) {
containerRef.current?.classList.add('crt-flicker');
// Remove flicker class after 50ms
setTimeout(() => {
containerRef.current?.classList.remove('crt-flicker');
}, 50);
}
}, 100);
}
rafId = requestAnimationFrame(tick);
};

return () => clearInterval(flickerInterval);
}
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [flicker]);

const classNames = [
Expand Down
28 changes: 21 additions & 7 deletions src/components/MatrixRain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const MatrixRain: React.FC<MatrixRainProps> = ({ theme = 'matrix' }) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;

const isFirefox = navigator.userAgent.includes('Firefox');

const updateCanvasSize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
Expand All @@ -76,9 +78,9 @@ export const MatrixRain: React.FC<MatrixRainProps> = ({ theme = 'matrix' }) => {
const binaryChars = '01';
const fontSize = 18;

// 40% fewer columns on mobile devices for performance
// Reduce columns on mobile and Firefox for performance
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const performanceMultiplier = isMobile ? 0.6 : 1;
const performanceMultiplier = isMobile ? 0.6 : isFirefox ? 0.7 : 1;
const columns = Math.floor((canvas.width / fontSize) * performanceMultiplier) + 1;

dropsRef.current = Array(columns).fill(null).map(() => {
Expand Down Expand Up @@ -110,8 +112,20 @@ export const MatrixRain: React.FC<MatrixRainProps> = ({ theme = 'matrix' }) => {

dropsRef.current.forEach((_, index) => animateDrop(index));

const draw = () => {
ctx.fillStyle = isMobile ? 'rgba(0, 0, 0, 0.08)' : 'rgba(0, 0, 0, 0.04)';
// Firefox: throttle to ~30fps and use faster fade
const targetInterval = isFirefox ? 33 : 0; // 33ms ≈ 30fps
let lastFrameTime = 0;

const draw = (timestamp?: number) => {
if (targetInterval && timestamp) {
if (timestamp - lastFrameTime < targetInterval) {
animationRef.current = requestAnimationFrame(draw);
return;
}
lastFrameTime = timestamp;
}

ctx.fillStyle = (isMobile || isFirefox) ? 'rgba(0, 0, 0, 0.08)' : 'rgba(0, 0, 0, 0.04)';
ctx.fillRect(0, 0, canvas.width, canvas.height);

ctx.font = `${fontSize}px 'Share Tech Mono', monospace`;
Expand Down Expand Up @@ -161,17 +175,17 @@ export const MatrixRain: React.FC<MatrixRainProps> = ({ theme = 'matrix' }) => {
color === '#39FF14' ? '57, 255, 20' : '0, 255, 0';

if (i === drop.chars.length - 1) {
ctx.shadowBlur = 25;
ctx.shadowBlur = isFirefox ? 0 : 25;
ctx.shadowColor = color;
ctx.fillStyle = '#ffffff';
ctx.font = `${fontSize * 1.2}px 'Share Tech Mono', monospace`;
} else if (i >= drop.chars.length - 3) {
const glowIntensity = 15 - (drop.chars.length - 1 - i) * 4;
ctx.shadowBlur = glowIntensity;
ctx.shadowBlur = isFirefox ? 0 : glowIntensity;
ctx.shadowColor = color;
ctx.fillStyle = `rgba(255, 255, 255, ${opacity * 1.3})`;
} else if (i >= drop.chars.length - 10) {
ctx.shadowBlur = 2;
ctx.shadowBlur = isFirefox ? 0 : 2;
ctx.shadowColor = color;
ctx.fillStyle = `rgba(${rgb}, ${opacity * 1.1})`;
} else {
Expand Down
25 changes: 18 additions & 7 deletions src/components/PageTransition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,24 @@ export const PageTransition: React.FC<PageTransitionProps> = ({
const glitchTransition = async () => {
const container = containerRef.current!;

await animate(container, [
{ filter: 'hue-rotate(0deg) contrast(1)' },
{ filter: 'hue-rotate(90deg) contrast(2)', offset: 0.1 },
{ filter: 'hue-rotate(-90deg) contrast(3)', offset: 0.2 },
{ filter: 'hue-rotate(180deg) contrast(1.5)', offset: 0.3 },
{ filter: 'hue-rotate(0deg) contrast(1)', offset: 1 }
], {
// Firefox: filter animations aren't GPU-accelerated, use opacity flash instead
if (navigator.userAgent.includes('Firefox')) {
await animate(container, {
opacity: [0.7, 1, 0.8, 1],
duration: 300,
easing: 'easeInOutQuad',
}).finished;
return;
}

await animate(container, {
filter: [
'hue-rotate(0deg) contrast(1)',
'hue-rotate(90deg) contrast(2)',
'hue-rotate(-90deg) contrast(3)',
'hue-rotate(180deg) contrast(1.5)',
'hue-rotate(0deg) contrast(1)'
],
duration: 500,
easing: 'easeInOutQuad',
}).finished;
Expand Down
24 changes: 20 additions & 4 deletions src/components/ParticleBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ export const ParticleBackground: React.FC = () => {
const ctx = canvas.getContext('2d');
if (!ctx) return;

const isFirefox = navigator.userAgent.includes('Firefox');

const updateCanvasSize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
updateCanvasSize();

const particleCount = Math.floor((canvas.width * canvas.height) / 7500);
// Halve particle count on Firefox to reduce O(n²) connection line calculations
const divisor = isFirefox ? 15000 : 7500;
const particleCount = Math.floor((canvas.width * canvas.height) / divisor);
particlesRef.current = [];

for (let i = 0; i < particleCount; i++) {
Expand All @@ -66,9 +70,20 @@ export const ParticleBackground: React.FC = () => {
};
window.addEventListener('mousemove', handleMouseMove);

const animate = () => {
const targetInterval = isFirefox ? 33 : 0;
let lastFrameTime = 0;

const animate = (timestamp?: number) => {
if (!ctx || !canvas) return;

if (targetInterval && timestamp) {
if (timestamp - lastFrameTime < targetInterval) {
animationRef.current = requestAnimationFrame(animate);
return;
}
lastFrameTime = timestamp;
}

ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);

Expand Down Expand Up @@ -104,14 +119,15 @@ export const ParticleBackground: React.FC = () => {
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fill();

const connectionDistance = isFirefox ? 100 : 180;
for (let j = index + 1; j < particlesRef.current.length; j++) {
const otherParticle = particlesRef.current[j];
const dx2 = particle.x - otherParticle.x;
const dy2 = particle.y - otherParticle.y;
const distance2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);

if (distance2 < 180) {
let opacity = (1 - distance2 / 180) * 0.2;
if (distance2 < connectionDistance) {
let opacity = (1 - distance2 / connectionDistance) * 0.2;

const midX = (particle.x + otherParticle.x) / 2;
const midY = (particle.y + otherParticle.y) / 2;
Expand Down
130 changes: 130 additions & 0 deletions src/css/firefox-performance.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/* ==========================================================================
Firefox Performance Overrides
Reduces expensive CSS effects that Firefox renders less efficiently
than Chromium browsers. Applied via .is-firefox class on <html>.
========================================================================== */

/* ==========================================================================
Backdrop Filter Reduction
Firefox supports backdrop-filter but renders it expensively.
Replace blur effects with solid semi-transparent backgrounds.
========================================================================== */

.is-firefox header,
.is-firefox footer,
.is-firefox .footer,
.is-firefox section,
.is-firefox form,
.is-firefox .form-container,
.is-firefox .matrix-spinner-overlay,
.is-firefox nav ul,
.is-firefox nav ul.mobile-open,
.is-firefox header.scrolled,
.is-firefox .backdrop-performance {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}

/* Compensate for lost blur with slightly more opaque backgrounds */
.is-firefox header {
background: rgba(26, 26, 26, 0.98) !important;
}

.is-firefox footer,
.is-firefox .footer {
background: rgba(26, 26, 26, 0.95) !important;
}

.is-firefox section {
background-color: var(--matrix-darker) !important;
}

.is-firefox nav ul {
background: rgba(0, 20, 0, 0.98) !important;
}

.is-firefox form,
.is-firefox .form-container {
background: rgba(0, 20, 0, 0.85) !important;
}

/* ==========================================================================
Will-Change Cleanup
Firefox doesn't release will-change layers efficiently.
Remove persistent will-change; let the browser optimize naturally.
========================================================================== */

.is-firefox .mainContent,
.is-firefox header,
.is-firefox footer,
.is-firefox .back-to-top {
will-change: auto !important;
}

.is-firefox .will-change-transform,
.is-firefox .will-change-opacity,
.is-firefox .will-change-filter,
.is-firefox .will-change-contents,
.is-firefox .interactive,
.is-firefox .gpu-accelerated {
will-change: auto !important;
}

/* ==========================================================================
Filter Animation Reduction
CSS filter animations (hue-rotate, brightness, blur) are not
GPU-accelerated in Firefox. Simplify or disable them.
========================================================================== */

.is-firefox .glitch-text:hover {
animation: none !important;
}

.is-firefox .matrix-rain-background {
filter: none !important;
}

/* ==========================================================================
Scanline Overlay Optimization
The full-screen repeating-gradient scanline is expensive in Firefox.
Reduce opacity to minimize compositing cost.
========================================================================== */

.is-firefox [data-theme="matrix"] body::after {
opacity: 0.2;
}
Comment on lines +93 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== data-theme assignments in source =="
rg -nP 'data-theme|setAttribute\(\s*["'\'']data-theme["'\'']|dataset\.theme' --type=ts --type=tsx --type=js --type=jsx --type=html src

echo
echo "== matrix + body::after selectors =="
rg -nP '\[data-theme="matrix"\].*body::after|body\[data-theme="matrix"\]::after|body::after' --type=css src/css

Repository: ThomasJButler/thomasjbutler.github.io

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Searching for data-theme assignments =="
rg 'data-theme' src --type=ts --type=js --type=html -n

echo ""
echo "== CSS selectors involving data-theme in firefox-performance.css =="
cat -n src/css/firefox-performance.css | grep -A2 -B2 'data-theme' || echo "Not found with grep, checking with ripgrep..."
rg 'data-theme' src/css/firefox-performance.css -n

echo ""
echo "== Check main.tsx for is-firefox class application =="
rg 'is-firefox' src -n

echo ""
echo "== Check for body element with data-theme =="
rg 'body.*data-theme|data-theme.*body' src --type=ts --type=js --type=html -n

Repository: ThomasJButler/thomasjbutler.github.io

Length of output: 4195


Fix unreachable CSS selector for scanline opacity override.

Line 93 (.is-firefox [data-theme="matrix"] body::after) is unreachable because data-theme is applied to the html element itself (via document.documentElement.setAttribute('data-theme', theme) in ThemeContext.tsx), not to a descendant. The current selector tries to match [data-theme="matrix"] as a descendant of .is-firefox (which is the html element), making it impossible to match.

Proposed fix
-.is-firefox [data-theme="matrix"] body::after {
+.is-firefox[data-theme="matrix"] body::after {
   opacity: 0.2;
 }

Combine the .is-firefox and [data-theme="matrix"] selectors on the same element (html) since both conditions are applied to it.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.is-firefox [data-theme="matrix"] body::after {
opacity: 0.2;
}
.is-firefox[data-theme="matrix"] body::after {
opacity: 0.2;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/css/firefox-performance.css` around lines 93 - 95, The selector
.is-firefox [data-theme="matrix"] body::after is unreachable because data-theme
is set on the same element as .is-firefox; update the selector to combine them
on the same element (e.g. .is-firefox[data-theme="matrix"] body::after or
html.is-firefox[data-theme="matrix"] body::after) so the scanline opacity
override actually matches and applies.


/* ==========================================================================
Text Shadow / Glow Reduction
Firefox renders multi-layer text-shadow less efficiently.
Simplify to single-layer glows.
========================================================================== */

.is-firefox [data-theme="matrix"] h1,
.is-firefox [data-theme="matrix"] h2,
.is-firefox [data-theme="matrix"] .section-title,
.is-firefox [data-theme="matrix"] .page-heading {
text-shadow: 0 0 10px var(--glow-color-primary) !important;
}

/* ==========================================================================
Transition Simplification
Reduce the global wildcard transition on theme changes.
========================================================================== */

.is-firefox * {
transition-property: none !important;
}

.is-firefox a,
.is-firefox button,
.is-firefox input,
.is-firefox textarea,
.is-firefox header,
.is-firefox [class*="card"],
.is-firefox [class*="btn"],
.is-firefox .menu-toggle,
.is-firefox nav ul {
transition-property: color, background-color, border-color, opacity, transform, box-shadow !important;
transition-duration: 0.2s !important;
}
3 changes: 3 additions & 0 deletions src/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@
/* Contact Modern - Comprehensive contact styling */
@import './contact-modern.css';

/* Firefox Performance - Must be last to override other styles */
@import './firefox-performance.css';

Loading
Loading