diff --git a/.jules/bolt.md b/.jules/bolt.md index a7efe75..8242db8 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -1,3 +1,7 @@ ## 2024-05-22 - Missing Scripts and Environment **Learning:** The project lacks `lint` script in `package.json`. Running `pnpm lint` might invoke system tools (like Android Lint?) instead of failing or doing nothing useful. Always check `package.json` scripts first. **Action:** Use specific commands like `pnpm exec tsc --noEmit` or `npx vitest` as discovered/documented, rather than assuming standard scripts exist. + +## 2024-05-22 - High-Frequency State Isolation +**Learning:** High-frequency state updates (like typing effects) in large parent components (`Hero`) trigger massive unnecessary re-renders of expensive sub-trees (`GradientBlinds`, `Button`). +**Action:** Isolate high-frequency state into small, leaf-node components (e.g., `TypedText`) and wrap them in `React.memo` if necessary, keeping the heavy parent static. diff --git a/.jules/palette.md b/.jules/palette.md index e9e6383..9b34d55 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,6 @@ ## 2025-02-18 - Missing Alerts for Dynamic Status **Learning:** The application uses `framer-motion` for dynamic feedback messages but consistently lacks `role="alert"` and `aria-live` attributes, causing screen readers to miss critical status updates. **Action:** When auditing forms, check all `motion.div/p` elements used for feedback and add `role="alert"` and `aria-live="polite"` (or "assertive" for errors). +## 2024-05-22 - Semantic Required Fields with Custom Validation +**Learning:** To combine custom validation UI with semantic `required` attributes (vital for a11y), add `noValidate` to the `
`. This prevents native browser bubbles while keeping the accessibility benefits. +**Action:** Use `noValidate` on forms when implementing custom validation but keep `required` attributes on inputs. diff --git a/firebase.json b/firebase.json index 50e9a41..1fc4858 100644 --- a/firebase.json +++ b/firebase.json @@ -24,6 +24,14 @@ "key": "X-Frame-Options", "value": "DENY" }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" diff --git a/src/components/effects/GradientBlinds.test.tsx b/src/components/effects/GradientBlinds.test.tsx index fdb4c45..2d3eb5b 100644 --- a/src/components/effects/GradientBlinds.test.tsx +++ b/src/components/effects/GradientBlinds.test.tsx @@ -176,17 +176,11 @@ describe('GradientBlinds', () => { unmount(); expect(removeEventListenerSpy).toHaveBeenCalledWith('pointermove', expect.any(Function)); }); + it('minimizes getBoundingClientRect calls during pointer move', () => { const { unmount } = render(); - // Spy on getBoundingClientRect - // Note: In jsdom, canvas is an HTMLCanvasElement which inherits from HTMLElement const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); - - // Trigger pointer move to clear any initial calls or verify baseline - // The initial render calls resize(), which calls getBoundingClientRect on container - - // Clear spy history from initial render spy.mockClear(); act(() => { @@ -198,9 +192,37 @@ describe('GradientBlinds', () => { window.dispatchEvent(event); }); - // EXPECTATION: It should NOT be called because the listener shouldn't be attached (not visible) expect(spy).not.toHaveBeenCalled(); unmount(); }); + + it('avoids expensive DOM reads (scrollX/Y) in pointermove handler when visible', () => { + const { unmount } = render(); + + // Spy on scrollX/scrollY getters + // Note: In jsdom, these are properties on window. + const scrollSpy = vi.spyOn(window, 'scrollX', 'get'); + + // Make visible to attach listener + act(() => { + if (ioCallback) { + ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); + } + }); + + scrollSpy.mockClear(); + + // Trigger pointer move + act(() => { + window.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 100 })); + }); + + // With the optimization (moving to RAF loop), this should be 0. + // Without optimization, this will be > 0. + // Since we are mocking RAF and not running the loop, if it's in the loop, it won't be called. + expect(scrollSpy).not.toHaveBeenCalled(); + + unmount(); + }); }); diff --git a/src/components/effects/GradientBlinds.tsx b/src/components/effects/GradientBlinds.tsx index 48c0a71..f53da04 100644 --- a/src/components/effects/GradientBlinds.tsx +++ b/src/components/effects/GradientBlinds.tsx @@ -64,6 +64,8 @@ const GradientBlinds: React.FC = ({ const geometryRef = useRef(null); const rendererRef = useRef(null); const mouseTargetRef = useRef<[number, number]>([0, 0]); + // Optimization: store raw pointer position (viewport coords) to decouple event handling from calculation + const pointerPosRef = useRef<{ x: number; y: number } | null>(null); const lastTimeRef = useRef(0); const firstResizeRef = useRef(true); const rectRef = useRef(null); @@ -309,31 +311,34 @@ void main() { ro.observe(container); const onPointerMove = (e: PointerEvent) => { - const scale = (renderer as unknown as { dpr?: number }).dpr || 1; - let x, y; - - if (rectRef.current) { - const dx = window.scrollX - scrollPosRef.current.x; - const dy = window.scrollY - scrollPosRef.current.y; - const rectLeft = rectRef.current.left - dx; - const rectTop = rectRef.current.top - dy; - x = (e.clientX - rectLeft) * scale; - y = (rectRef.current.height - (e.clientY - rectTop)) * scale; - } else { - const rect = canvas.getBoundingClientRect(); - x = (e.clientX - rect.left) * scale; - y = (rect.height - (e.clientY - rect.top)) * scale; - } - - mouseTargetRef.current = [x, y]; - if (mouseDampening <= 0) { - uniforms.iMouse.value = [x, y]; - } + pointerPosRef.current = { x: e.clientX, y: e.clientY }; }; const loop = (t: number) => { rafRef.current = requestAnimationFrame(loop); uniforms.iTime.value = t * 0.001; + + // Update target based on pointer position and scroll offset + if (pointerPosRef.current) { + const scale = (renderer as unknown as { dpr?: number }).dpr || 1; + let x, y; + + if (rectRef.current) { + const dx = window.scrollX - scrollPosRef.current.x; + const dy = window.scrollY - scrollPosRef.current.y; + const rectLeft = rectRef.current.left - dx; + const rectTop = rectRef.current.top - dy; + x = (pointerPosRef.current.x - rectLeft) * scale; + y = (rectRef.current.height - (pointerPosRef.current.y - rectTop)) * scale; + } else { + // Fallback if rectRef missing + const rect = canvas.getBoundingClientRect(); + x = (pointerPosRef.current.x - rect.left) * scale; + y = (rect.height - (pointerPosRef.current.y - rect.top)) * scale; + } + mouseTargetRef.current = [x, y]; + } + if (mouseDampening > 0) { if (!lastTimeRef.current) lastTimeRef.current = t; const dt = (t - lastTimeRef.current) / 1000; @@ -346,6 +351,9 @@ void main() { cur[0] += (target[0] - cur[0]) * factor; cur[1] += (target[1] - cur[1]) * factor; } else { + if (pointerPosRef.current) { + uniforms.iMouse.value = mouseTargetRef.current; + } lastTimeRef.current = t; } if (!paused && programRef.current && meshRef.current) { diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index b2c0d21..e9ef169 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, memo } from "react"; import { Link } from "react-router-dom"; import { motion } from "motion/react"; import { useTranslation } from "../../i18n"; @@ -9,6 +9,21 @@ import styles from "./Hero.module.css"; const GRADIENT_COLORS = ["#26a269", "#8ff0a4"]; +interface TypedTextProps { + words: string[]; +} + +const TypedText = memo(({ words }: TypedTextProps) => { + const { text } = useTypingEffect({ + words, + typingSpeed: 80, + deletingSpeed: 40, + pauseDuration: 2500, + }); + + return <>{text}; +}); + export function Hero() { const { t } = useTranslation(); const [showScrollIndicator, setShowScrollIndicator] = useState(true); @@ -29,13 +44,6 @@ export function Hero() { return () => observer.disconnect(); }, []); - const { text } = useTypingEffect({ - words: t.hero.rotatingWords, - typingSpeed: 80, - deletingSpeed: 40, - pauseDuration: 2500, - }); - return (
{t.hero.tagline} - {text} + | diff --git a/src/components/ui/Input.module.css b/src/components/ui/Input.module.css index 6cb7aa9..3acc481 100644 --- a/src/components/ui/Input.module.css +++ b/src/components/ui/Input.module.css @@ -10,6 +10,11 @@ color: var(--md-sys-color-on-surface); } +.required { + color: var(--md-sys-color-error); + margin-left: 0.25rem; +} + .input { padding: var(--space-md); font-family: var(--md-sys-typescale-body-font); diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index b3d0c24..185bfbe 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -15,6 +15,11 @@ export const Input = forwardRef(
(