Skip to content

Commit 34e3dbf

Browse files
Merge pull request #61 from ThomasJButler/v3.5-DependencyUpdate
Fix Firefox performance
2 parents 68da9d1 + d5251b5 commit 34e3dbf

9 files changed

Lines changed: 293 additions & 35 deletions

File tree

.claude/CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
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.
8+
9+
## Commands
10+
11+
| Command | Purpose |
12+
|---------|---------|
13+
| `npm run dev` | Dev server on port 3000 (opens /react.html) |
14+
| `npm run build` | Production build to dist/ |
15+
| `npm run preview` | Preview production build |
16+
| `npm run lint` | ESLint on src/**/*.{js,ts} |
17+
| `npm run format` | Prettier on src/**/*.{js,ts,css,html} |
18+
| `npm run type-check` | TypeScript check (no emit) |
19+
| `npm run test` | Vitest |
20+
| `npm run test:ui` | Vitest with UI |
21+
| `npm run test:coverage` | Vitest with coverage |
22+
| `npm run deploy` | Build + deploy to GitHub Pages via gh-pages |
23+
24+
## Architecture
25+
26+
**Stack:** React 19, TypeScript (strict), Vite 7, React Router v7 (BrowserRouter)
27+
28+
**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.
29+
30+
**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`.
31+
32+
**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`).
33+
34+
**Animation libraries:** GSAP, Anime.js (v4), Framer Motion, AOS (Animate On Scroll), ScrollMagic. Matrix rain, CRT effects, and particle backgrounds are custom implementations.
35+
36+
**Path aliases:** `@/` -> `src/`, plus `@components/`, `@pages/`, `@hooks/`, `@utils/`, `@css/`, `@js/`, `@images/` (configured in both tsconfig.json and vite.config.mjs).
37+
38+
**Key directories:**
39+
- `src/pages/` — Route-level page components
40+
- `src/components/` — Reusable UI components
41+
- `src/hooks/` — Custom hooks (scroll animation, lazy loading, SEO, performance)
42+
- `src/utils/` — Utilities (keyboard nav, performance optimizer, animations)
43+
- `src/contexts/` — React Context (ThemeContext for light/dark mode)
44+
- `src/css/` — Organized stylesheet modules
45+
- `src/content/blog/` — Markdown blog posts (copied to dist on build)
46+
47+
## Code Style
48+
49+
- Prettier: single quotes, semicolons, 100 char width, trailing commas (es5)
50+
- ESLint: TypeScript strict, React hooks rules, no-var, no-require
51+
- Components use named exports (not default), except where lazy loading requires `.then(m => ({ default: m.ComponentName }))`
52+
- Global animation libs (anime, gsap, ScrollMagic, AOS) are declared as ESLint globals
53+
54+
## Deployment
55+
56+
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.

src/components/CRTEffect.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,28 @@ export const CRTEffect: React.FC<CRTEffectProps> = ({
4040
* @listens flicker - Initialises random flicker intervals when enabled
4141
*/
4242
useEffect(() => {
43-
if (!containerRef.current) return;
43+
if (!containerRef.current || !flicker) return;
4444

45-
if (flicker) {
46-
const flickerInterval = setInterval(() => {
45+
let rafId: number;
46+
let lastFlicker = 0;
47+
48+
const tick = (timestamp: number) => {
49+
// Check roughly every 100ms (but via RAF, not setInterval)
50+
if (timestamp - lastFlicker > 100) {
51+
lastFlicker = timestamp;
4752
if (Math.random() > 0.99) {
4853
containerRef.current?.classList.add('crt-flicker');
54+
// Remove flicker class after 50ms
4955
setTimeout(() => {
5056
containerRef.current?.classList.remove('crt-flicker');
5157
}, 50);
5258
}
53-
}, 100);
59+
}
60+
rafId = requestAnimationFrame(tick);
61+
};
5462

55-
return () => clearInterval(flickerInterval);
56-
}
63+
rafId = requestAnimationFrame(tick);
64+
return () => cancelAnimationFrame(rafId);
5765
}, [flicker]);
5866

5967
const classNames = [

src/components/MatrixRain.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export const MatrixRain: React.FC<MatrixRainProps> = ({ theme = 'matrix' }) => {
6666
const ctx = canvas.getContext('2d');
6767
if (!ctx) return;
6868

69+
const isFirefox = navigator.userAgent.includes('Firefox');
70+
6971
const updateCanvasSize = () => {
7072
canvas.width = window.innerWidth;
7173
canvas.height = window.innerHeight;
@@ -76,9 +78,9 @@ export const MatrixRain: React.FC<MatrixRainProps> = ({ theme = 'matrix' }) => {
7678
const binaryChars = '01';
7779
const fontSize = 18;
7880

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

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

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

113-
const draw = () => {
114-
ctx.fillStyle = isMobile ? 'rgba(0, 0, 0, 0.08)' : 'rgba(0, 0, 0, 0.04)';
115+
// Firefox: throttle to ~30fps and use faster fade
116+
const targetInterval = isFirefox ? 33 : 0; // 33ms ≈ 30fps
117+
let lastFrameTime = 0;
118+
119+
const draw = (timestamp?: number) => {
120+
if (targetInterval && timestamp) {
121+
if (timestamp - lastFrameTime < targetInterval) {
122+
animationRef.current = requestAnimationFrame(draw);
123+
return;
124+
}
125+
lastFrameTime = timestamp;
126+
}
127+
128+
ctx.fillStyle = (isMobile || isFirefox) ? 'rgba(0, 0, 0, 0.08)' : 'rgba(0, 0, 0, 0.04)';
115129
ctx.fillRect(0, 0, canvas.width, canvas.height);
116130

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

163177
if (i === drop.chars.length - 1) {
164-
ctx.shadowBlur = 25;
178+
ctx.shadowBlur = isFirefox ? 0 : 25;
165179
ctx.shadowColor = color;
166180
ctx.fillStyle = '#ffffff';
167181
ctx.font = `${fontSize * 1.2}px 'Share Tech Mono', monospace`;
168182
} else if (i >= drop.chars.length - 3) {
169183
const glowIntensity = 15 - (drop.chars.length - 1 - i) * 4;
170-
ctx.shadowBlur = glowIntensity;
184+
ctx.shadowBlur = isFirefox ? 0 : glowIntensity;
171185
ctx.shadowColor = color;
172186
ctx.fillStyle = `rgba(255, 255, 255, ${opacity * 1.3})`;
173187
} else if (i >= drop.chars.length - 10) {
174-
ctx.shadowBlur = 2;
188+
ctx.shadowBlur = isFirefox ? 0 : 2;
175189
ctx.shadowColor = color;
176190
ctx.fillStyle = `rgba(${rgb}, ${opacity * 1.1})`;
177191
} else {

src/components/PageTransition.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,24 @@ export const PageTransition: React.FC<PageTransitionProps> = ({
7979
const glitchTransition = async () => {
8080
const container = containerRef.current!;
8181

82-
await animate(container, [
83-
{ filter: 'hue-rotate(0deg) contrast(1)' },
84-
{ filter: 'hue-rotate(90deg) contrast(2)', offset: 0.1 },
85-
{ filter: 'hue-rotate(-90deg) contrast(3)', offset: 0.2 },
86-
{ filter: 'hue-rotate(180deg) contrast(1.5)', offset: 0.3 },
87-
{ filter: 'hue-rotate(0deg) contrast(1)', offset: 1 }
88-
], {
82+
// Firefox: filter animations aren't GPU-accelerated, use opacity flash instead
83+
if (navigator.userAgent.includes('Firefox')) {
84+
await animate(container, {
85+
opacity: [0.7, 1, 0.8, 1],
86+
duration: 300,
87+
easing: 'easeInOutQuad',
88+
}).finished;
89+
return;
90+
}
91+
92+
await animate(container, {
93+
filter: [
94+
'hue-rotate(0deg) contrast(1)',
95+
'hue-rotate(90deg) contrast(2)',
96+
'hue-rotate(-90deg) contrast(3)',
97+
'hue-rotate(180deg) contrast(1.5)',
98+
'hue-rotate(0deg) contrast(1)'
99+
],
89100
duration: 500,
90101
easing: 'easeInOutQuad',
91102
}).finished;

src/components/ParticleBackground.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,17 @@ export const ParticleBackground: React.FC = () => {
4040
const ctx = canvas.getContext('2d');
4141
if (!ctx) return;
4242

43+
const isFirefox = navigator.userAgent.includes('Firefox');
44+
4345
const updateCanvasSize = () => {
4446
canvas.width = window.innerWidth;
4547
canvas.height = window.innerHeight;
4648
};
4749
updateCanvasSize();
4850

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

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

69-
const animate = () => {
73+
const targetInterval = isFirefox ? 33 : 0;
74+
let lastFrameTime = 0;
75+
76+
const animate = (timestamp?: number) => {
7077
if (!ctx || !canvas) return;
7178

79+
if (targetInterval && timestamp) {
80+
if (timestamp - lastFrameTime < targetInterval) {
81+
animationRef.current = requestAnimationFrame(animate);
82+
return;
83+
}
84+
lastFrameTime = timestamp;
85+
}
86+
7287
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
7388
ctx.fillRect(0, 0, canvas.width, canvas.height);
7489

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

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

113-
if (distance2 < 180) {
114-
let opacity = (1 - distance2 / 180) * 0.2;
129+
if (distance2 < connectionDistance) {
130+
let opacity = (1 - distance2 / connectionDistance) * 0.2;
115131

116132
const midX = (particle.x + otherParticle.x) / 2;
117133
const midY = (particle.y + otherParticle.y) / 2;

src/css/firefox-performance.css

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/* ==========================================================================
2+
Firefox Performance Overrides
3+
Reduces expensive CSS effects that Firefox renders less efficiently
4+
than Chromium browsers. Applied via .is-firefox class on <html>.
5+
========================================================================== */
6+
7+
/* ==========================================================================
8+
Backdrop Filter Reduction
9+
Firefox supports backdrop-filter but renders it expensively.
10+
Replace blur effects with solid semi-transparent backgrounds.
11+
========================================================================== */
12+
13+
.is-firefox header,
14+
.is-firefox footer,
15+
.is-firefox .footer,
16+
.is-firefox section,
17+
.is-firefox form,
18+
.is-firefox .form-container,
19+
.is-firefox .matrix-spinner-overlay,
20+
.is-firefox nav ul,
21+
.is-firefox nav ul.mobile-open,
22+
.is-firefox header.scrolled,
23+
.is-firefox .backdrop-performance {
24+
backdrop-filter: none !important;
25+
-webkit-backdrop-filter: none !important;
26+
}
27+
28+
/* Compensate for lost blur with slightly more opaque backgrounds */
29+
.is-firefox header {
30+
background: rgba(26, 26, 26, 0.98) !important;
31+
}
32+
33+
.is-firefox footer,
34+
.is-firefox .footer {
35+
background: rgba(26, 26, 26, 0.95) !important;
36+
}
37+
38+
.is-firefox section {
39+
background-color: var(--matrix-darker) !important;
40+
}
41+
42+
.is-firefox nav ul {
43+
background: rgba(0, 20, 0, 0.98) !important;
44+
}
45+
46+
.is-firefox form,
47+
.is-firefox .form-container {
48+
background: rgba(0, 20, 0, 0.85) !important;
49+
}
50+
51+
/* ==========================================================================
52+
Will-Change Cleanup
53+
Firefox doesn't release will-change layers efficiently.
54+
Remove persistent will-change; let the browser optimize naturally.
55+
========================================================================== */
56+
57+
.is-firefox .mainContent,
58+
.is-firefox header,
59+
.is-firefox footer,
60+
.is-firefox .back-to-top {
61+
will-change: auto !important;
62+
}
63+
64+
.is-firefox .will-change-transform,
65+
.is-firefox .will-change-opacity,
66+
.is-firefox .will-change-filter,
67+
.is-firefox .will-change-contents,
68+
.is-firefox .interactive,
69+
.is-firefox .gpu-accelerated {
70+
will-change: auto !important;
71+
}
72+
73+
/* ==========================================================================
74+
Filter Animation Reduction
75+
CSS filter animations (hue-rotate, brightness, blur) are not
76+
GPU-accelerated in Firefox. Simplify or disable them.
77+
========================================================================== */
78+
79+
.is-firefox .glitch-text:hover {
80+
animation: none !important;
81+
}
82+
83+
.is-firefox .matrix-rain-background {
84+
filter: none !important;
85+
}
86+
87+
/* ==========================================================================
88+
Scanline Overlay Optimization
89+
The full-screen repeating-gradient scanline is expensive in Firefox.
90+
Reduce opacity to minimize compositing cost.
91+
========================================================================== */
92+
93+
.is-firefox [data-theme="matrix"] body::after {
94+
opacity: 0.2;
95+
}
96+
97+
/* ==========================================================================
98+
Text Shadow / Glow Reduction
99+
Firefox renders multi-layer text-shadow less efficiently.
100+
Simplify to single-layer glows.
101+
========================================================================== */
102+
103+
.is-firefox [data-theme="matrix"] h1,
104+
.is-firefox [data-theme="matrix"] h2,
105+
.is-firefox [data-theme="matrix"] .section-title,
106+
.is-firefox [data-theme="matrix"] .page-heading {
107+
text-shadow: 0 0 10px var(--glow-color-primary) !important;
108+
}
109+
110+
/* ==========================================================================
111+
Transition Simplification
112+
Reduce the global wildcard transition on theme changes.
113+
========================================================================== */
114+
115+
.is-firefox * {
116+
transition-property: none !important;
117+
}
118+
119+
.is-firefox a,
120+
.is-firefox button,
121+
.is-firefox input,
122+
.is-firefox textarea,
123+
.is-firefox header,
124+
.is-firefox [class*="card"],
125+
.is-firefox [class*="btn"],
126+
.is-firefox .menu-toggle,
127+
.is-firefox nav ul {
128+
transition-property: color, background-color, border-color, opacity, transform, box-shadow !important;
129+
transition-duration: 0.2s !important;
130+
}

src/css/main.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@
4343
/* Contact Modern - Comprehensive contact styling */
4444
@import './contact-modern.css';
4545

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

0 commit comments

Comments
 (0)