From c7ff3282b205e134f1967de87b0fa4c9f380e5af Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Apr 2026 17:51:10 -0700 Subject: [PATCH 01/34] fix(website): add track shake animation to ProblemSection stall phase Co-Authored-By: Claude Sonnet 4.6 --- apps/website/src/components/landing/ProblemSection.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/website/src/components/landing/ProblemSection.tsx b/apps/website/src/components/landing/ProblemSection.tsx index 283232b3a..028147121 100644 --- a/apps/website/src/components/landing/ProblemSection.tsx +++ b/apps/website/src/components/landing/ProblemSection.tsx @@ -213,6 +213,7 @@ export function ProblemSection() { background: 'rgba(0,0,0,0.07)', overflow: 'hidden', position: 'relative', + animation: showStall ? 'sr-shake 0.5s ease-in-out' : 'none', }}>
{` @keyframes sr-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(.6)} } + @keyframes sr-shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-3px)} 40%{transform:translateX(3px)} 60%{transform:translateX(-2px)} 80%{transform:translateX(2px)} } `} ); From 01840280e89bb62b3302166ced69375d2e2c59e6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 5 Apr 2026 17:55:26 -0700 Subject: [PATCH 02/34] =?UTF-8?q?fix(website):=20ProblemSection=20quality?= =?UTF-8?q?=20fixes=20=E2=80=94=20timer=20cleanup,=20unique=20SVG=20ID,=20?= =?UTF-8?q?aria-hidden,=20correct=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store setTimeout IDs and clear them on unmount (prevents state updates on unmounted component) - Use useId() to generate unique hatchId per instance (prevents SVG pattern id collision) - Add role=progressbar + aria-valuenow to track container for screen readers - Add aria-hidden=true to decorative animated elements (pins, labels, badge, counter) - Fix import: use local lib/design-tokens instead of unresolved @cacheplane/design-tokens - Add invariant comment for done-timeout vs counter-duration coupling Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/landing/ProblemSection.tsx | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/apps/website/src/components/landing/ProblemSection.tsx b/apps/website/src/components/landing/ProblemSection.tsx index 028147121..37f18d69e 100644 --- a/apps/website/src/components/landing/ProblemSection.tsx +++ b/apps/website/src/components/landing/ProblemSection.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useRef, useEffect, useState, useCallback } from 'react'; +import { useRef, useEffect, useState, useCallback, useId } from 'react'; import { motion, useInView } from 'framer-motion'; -import { tokens } from '@cacheplane/design-tokens'; +import { tokens } from '../../../lib/design-tokens'; const STATS = [ { num: '66%', label: 'of AI solutions are almost right — not quite production-ready' }, @@ -30,6 +30,9 @@ function useCounter(target: number, duration: number, running: boolean) { type Phase = 'idle' | 'filling' | 'stall' | 'closing' | 'done'; export function ProblemSection() { + const uid = useId(); + const hatchId = `gap-hatch-${uid.replace(/:/g, '')}`; + const triggerRef = useRef(null); const inView = useInView(triggerRef, { once: true, amount: 0.3 }); const [phase, setPhase] = useState('idle'); @@ -42,33 +45,39 @@ export function ProblemSection() { const counterRunning100 = phase === 'closing' || phase === 'done'; const count77 = useCounter(77, 1700, counterRunning77); const count100 = useCounter(23, 1000, counterRunning100); + // 'done' timeout (4400ms) fires after closing counter finishes (3200 + 1000 = 4200ms) + // so 77 + count100 always reaches 100 before the phase snaps to literal 100 const displayCount = phase === 'done' ? 100 : phase === 'closing' ? 77 + count100 : count77; const runAnimation = useCallback(() => { if (phase !== 'idle') return; - // Phase 1: fill to 77% - setTimeout(() => { + const timers: ReturnType[] = []; + // Phase 1 (150ms): fill to 77% + timers.push(setTimeout(() => { setFillTransition('width 1.7s cubic-bezier(.4,0,.2,1)'); setFillWidth('77%'); setPhase('filling'); - }, 150); - // Phase 2: stall - setTimeout(() => setPhase('stall'), 2100); - // Phase 3: close gap - setTimeout(() => { + }, 150)); + // Phase 2 (2100ms): stall — marker + hatch + shake + timers.push(setTimeout(() => setPhase('stall'), 2100)); + // Phase 3 (3200ms): close gap to 100% + timers.push(setTimeout(() => { setFillTransition('width 1s cubic-bezier(.4,0,.2,1)'); setFillGradient( 'linear-gradient(90deg, rgba(221,0,49,.5) 0%, rgba(221,0,49,.38) 70%, rgba(0,64,144,.8) 82%, #004090 100%)' ); setFillWidth('100%'); setPhase('closing'); - }, 3200); - // Phase 4: done - setTimeout(() => setPhase('done'), 4400); + }, 3200)); + // Phase 4 (4400ms): done — end labels + tagline + timers.push(setTimeout(() => setPhase('done'), 4400)); + return timers; }, [phase]); useEffect(() => { - if (inView) runAnimation(); + if (!inView) return; + const timers = runAnimation(); + return () => timers?.forEach(clearTimeout); }, [inView, runAnimation]); const showStall = phase === 'stall'; @@ -184,7 +193,8 @@ export function ProblemSection() { > {/* Labels row */}
- + + }} aria-hidden="true"> ⚠ Teams stall here + }} aria-hidden="true"> ✓ Production
{/* Track — overflow:hidden clips fill at container boundary, no border-radius artifact */} -
+
- {/* Hatch overlay (gap zone) */} -
- {/* Stall pin — outside the overflow:hidden track */} -