diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1fba5fa..c55c27e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -39,7 +39,10 @@ "Bash(python scripts/generate_demo_data.py:*)", "Bash(sqlite3:*)", "Bash(python:*)", - "Bash(grep:*)" + "Bash(grep:*)", + "WebFetch(domain:www.embla-carousel.com)", + "Bash(npm ls:*)", + "Bash(npm install:*)" ] } } diff --git a/.ralph/agent/handoff.md b/.ralph/agent/handoff.md new file mode 100644 index 0000000..4f920e2 --- /dev/null +++ b/.ralph/agent/handoff.md @@ -0,0 +1,46 @@ +# Session Handoff + +_Generated: 2026-02-17 21:19:40 UTC_ + +## Git Context + +- **Branch:** `master` +- **HEAD:** d51efb5: chore: auto-commit before merge (loop primary) + +## Tasks + +_No tasks tracked in this session._ + +## Key Files + +Recently modified: + +- `.ralph/agent/scratchpad.md` +- `.ralph/agent/summary.md` +- `.ralph/agent/tasks.jsonl.lock` +- `.ralph/current-events` +- `.ralph/current-loop-id` +- `.ralph/events-20260217-140400.jsonl` +- `.ralph/events-20260217-205901.jsonl` +- `.ralph/history.jsonl` +- `.ralph/loop.lock` +- `.ralph/plan.md` + +## Next Session + +Session completed successfully. No pending work. + +**Original objective:** + +``` +# Task: Fix Mobile Responsiveness for Small Viewport Widths (≤430px) + +The portfolio website is broken on phones with viewport widths around 400px. Text overflows off-screen, elements are hidden behind `overflow: hidden`, and layout components are sized inappropriately for small screens. + +## Context + +- **Tech stack:** React + TypeScript + Tailwind CSS + Framer Motion + D3 +- **Dev server:** `npm run dev` (localhost:5173) +- **Quality gates:** `npm run lint && npm run typecheck && npm run build` +-... +``` diff --git a/.ralph/loops.json b/.ralph/loops.json new file mode 100644 index 0000000..0462f9a --- /dev/null +++ b/.ralph/loops.json @@ -0,0 +1,3 @@ +{ + "loops": [] +} \ No newline at end of file diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx index 5c093c4..240ab00 100644 --- a/src/components/BootSequence.tsx +++ b/src/components/BootSequence.tsx @@ -424,7 +424,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) { } renderedLines.push( -
+
{spans}
) @@ -459,7 +459,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) { ) } lines.push( -
+
{spans}
) diff --git a/src/components/constellation/CareerConstellation.tsx b/src/components/constellation/CareerConstellation.tsx index d308210..a02b206 100644 --- a/src/components/constellation/CareerConstellation.tsx +++ b/src/components/constellation/CareerConstellation.tsx @@ -76,7 +76,7 @@ const CareerConstellation: React.FC = ({ let debounceTimer: ReturnType | null = null const X_CHANGE_THRESHOLD = 0.3 - const updateDimensions = () => { + const updateDimensions = (force = false) => { const width = container.clientWidth const viewportWidth = window.innerWidth const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight) @@ -84,30 +84,59 @@ const CareerConstellation: React.FC = ({ ? Math.max(1, Math.min(1.6, viewportWidth / 1440)) : 1 setDimensions(prev => { - const widthDelta = Math.abs(prev.width - width) / prev.width - const heightRatio = Math.max(height / prev.height, prev.height / height) - if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) { - return prev + if (!force) { + const widthDelta = Math.abs(prev.width - width) / prev.width + const heightRatio = Math.max(height / prev.height, prev.height / height) + if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) { + return prev + } } return { width, height, scaleFactor } }) } - // Use rAF for fullscreen toggle so CSS layout settles before measuring - requestAnimationFrame(updateDimensions) + // Force update on fullscreen/orientation change so animation always restarts + requestAnimationFrame(() => updateDimensions(true)) + + const onOrientationChange = () => { + requestAnimationFrame(() => updateDimensions(true)) + } + window.addEventListener('orientationchange', onOrientationChange) const observer = new ResizeObserver(() => { if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(updateDimensions, 2000) + debounceTimer = setTimeout(() => updateDimensions(), 2000) }) observer.observe(container) return () => { observer.disconnect() + window.removeEventListener('orientationchange', onOrientationChange) if (debounceTimer) clearTimeout(debounceTimer) } }, [containerHeight, isFullscreen]) - const toggleFullscreen = useCallback(() => setIsFullscreen(prev => !prev), []) + const toggleFullscreen = useCallback(() => { + const entering = !isFullscreen + setIsFullscreen(entering) + + if (entering) { + // On portrait touch devices, request native fullscreen + lock landscape + const isPortrait = window.matchMedia('(orientation: portrait)').matches + const isTouch = window.matchMedia('(pointer: coarse)').matches + if (isPortrait && isTouch && containerRef.current?.requestFullscreen) { + const so = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise } + containerRef.current.requestFullscreen() + .then(() => so.lock?.('landscape')) + .catch(() => {}) + } + } else { + const so = screen.orientation as ScreenOrientation & { unlock?: () => void } + try { so.unlock?.() } catch { /* not supported */ } + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}) + } + } + }, [isFullscreen]) // ESC key to exit fullscreen useEffect(() => { @@ -126,6 +155,17 @@ const CareerConstellation: React.FC = ({ return () => { document.body.style.overflow = '' } }, [isFullscreen]) + // Sync state when native fullscreen is exited via browser controls + useEffect(() => { + const handler = () => { + if (!document.fullscreenElement && isFullscreen) { + setIsFullscreen(false) + } + } + document.addEventListener('fullscreenchange', handler) + return () => document.removeEventListener('fullscreenchange', handler) + }, [isFullscreen]) + // Focus trap when fullscreen useFocusTrap(containerRef, isFullscreen) @@ -134,6 +174,8 @@ const CareerConstellation: React.FC = ({ const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf) const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf) + // pinnedNodeIdRef is declared later but only accessed at call-time (not during render), so empty dep arrays are correct + /* eslint-disable react-hooks/exhaustive-deps */ const resolveGraphFallback = useCallback( () => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current, [], @@ -148,6 +190,7 @@ const CareerConstellation: React.FC = ({ if (pId && pType && pType !== 'skill') return pId return null }, []) + /* eslint-enable react-hooks/exhaustive-deps */ // Shared refs for hooks const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) diff --git a/src/components/tiles/ProjectsTile.tsx b/src/components/tiles/ProjectsTile.tsx index c2d1500..5ad2104 100644 --- a/src/components/tiles/ProjectsTile.tsx +++ b/src/components/tiles/ProjectsTile.tsx @@ -1,4 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import Autoplay from 'embla-carousel-autoplay' import { investigations } from '@/data/investigations' import { CardHeader } from '../Card' import { useDetailPanel } from '@/contexts/DetailPanelContext' @@ -229,7 +231,129 @@ function ProjectItem({ ) } -export function ProjectsCarousel() { +// --- Embla slide-by-slide carousel for screens < 1024px --- + +function EmblaProjectsCarousel() { + const { openPanel } = useDetailPanel() + const wrapperRef = useRef(null) + const [wrapperWidth, setWrapperWidth] = useState(0) + const [selectedIndex, setSelectedIndex] = useState(0) + const [scrollSnaps, setScrollSnaps] = useState([]) + + useEffect(() => { + const el = wrapperRef.current + if (!el) return + const update = () => { + const w = el.clientWidth + if (w > 0) setWrapperWidth(w) + } + update() + const obs = new ResizeObserver(update) + obs.observe(el) + return () => obs.disconnect() + }, []) + + const slidesPerView = wrapperWidth < 480 ? 1 : 2 + const slideWidth = slidesPerView === 1 ? '100%' : 'calc(50% - 6px)' + const cardMinHeight = wrapperWidth < 480 ? 148 : wrapperWidth < 640 ? 168 : 182 + + const [emblaRef, emblaApi] = useEmblaCarousel( + { loop: true, align: 'start' }, + [Autoplay({ delay: 4000, stopOnInteraction: false, stopOnMouseEnter: true })], + ) + + useEffect(() => { + if (!emblaApi || typeof window === 'undefined') return + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const sync = () => { + const autoplay = emblaApi.plugins()?.autoplay + if (!autoplay) return + if (mq.matches) autoplay.stop() + else autoplay.play() + } + sync() + mq.addEventListener('change', sync) + return () => mq.removeEventListener('change', sync) + }, [emblaApi]) + + useEffect(() => { + emblaApi?.reInit() + }, [emblaApi, slidesPerView]) + + const onSelect = useCallback(() => { + if (!emblaApi) return + setSelectedIndex(emblaApi.selectedScrollSnap()) + }, [emblaApi]) + + useEffect(() => { + if (!emblaApi) return + const updateSnaps = () => { + setScrollSnaps(emblaApi.scrollSnapList()) + setSelectedIndex(emblaApi.selectedScrollSnap()) + } + updateSnaps() + emblaApi.on('select', onSelect) + emblaApi.on('reInit', updateSnaps) + return () => { + emblaApi.off('select', onSelect) + emblaApi.off('reInit', updateSnaps) + } + }, [emblaApi, onSelect]) + + return ( +
+
+
+ {investigations.map((project) => ( + openPanel({ type: 'project', investigation: project })} + /> + ))} +
+
+ {scrollSnaps.length > 1 && ( +
+ {scrollSnaps.map((_, index) => ( +
+ )} +
+ ) +} + +// --- Continuous scroll carousel for screens >= 1024px --- + +function ContinuousScrollCarousel() { const { openPanel } = useDetailPanel() const viewportRef = useRef(null) const trackRef = useRef(null) @@ -245,95 +369,64 @@ export function ProjectsCarousel() { useEffect(() => { const viewportEl = viewportRef.current - if (!viewportEl || typeof window === 'undefined') { - return - } - + if (!viewportEl || typeof window === 'undefined') return const updateWidth = () => { const nextWidth = viewportEl.clientWidth - if (nextWidth > 0) { - setViewportWidth(nextWidth) - } + if (nextWidth > 0) setViewportWidth(nextWidth) } updateWidth() - if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(() => updateWidth()) observer.observe(viewportEl) return () => observer.disconnect() } - window.addEventListener('resize', updateWidth) return () => window.removeEventListener('resize', updateWidth) }, []) useEffect(() => { - if (typeof window === 'undefined') { - return - } - + if (typeof window === 'undefined') return const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches) - syncMotionPreference() mediaQuery.addEventListener('change', syncMotionPreference) - return () => mediaQuery.removeEventListener('change', syncMotionPreference) }, []) useEffect(() => { const trackEl = trackRef.current const firstSetEl = firstSetRef.current - if (!trackEl || !firstSetEl || prefersReducedMotion) { - return - } - + if (!trackEl || !firstSetEl || prefersReducedMotion) return let animationFrameId = 0 let lastTime = 0 - const speedPxPerSecond = viewportWidth < 768 ? 18 : 24 - + const speedPxPerSecond = 24 const tick = (timestamp: number) => { - if (!lastTime) { - lastTime = timestamp - } + if (!lastTime) lastTime = timestamp const deltaSeconds = (timestamp - lastTime) / 1000 lastTime = timestamp - if (!isPausedRef.current) { const setWidth = firstSetEl.offsetWidth if (setWidth > 0) { offsetRef.current += speedPxPerSecond * deltaSeconds - if (offsetRef.current >= setWidth) { - offsetRef.current -= setWidth - } + if (offsetRef.current >= setWidth) offsetRef.current -= setWidth trackEl.style.transform = `translate3d(-${offsetRef.current}px, 0, 0)` } } - animationFrameId = window.requestAnimationFrame(tick) } - animationFrameId = window.requestAnimationFrame(tick) return () => window.cancelAnimationFrame(animationFrameId) }, [prefersReducedMotion, viewportWidth]) - const cardsPerView = useMemo(() => { - if (viewportWidth < 480) return 1 - if (viewportWidth < 768) return 2 - return 4 - }, [viewportWidth]) - const slideWidth = useMemo(() => { + const cardsPerView = 4 const gap = 12 const totalGap = (cardsPerView - 1) * gap const computedWidth = (viewportWidth - totalGap) / cardsPerView return `${Math.max(computedWidth, 0)}px` - }, [cardsPerView, viewportWidth]) + }, [viewportWidth]) const cardMinHeight = useMemo(() => { - if (viewportWidth < 480) return 148 - if (viewportWidth < 640) return 168 - if (viewportWidth < 1024) return 182 if (viewportWidth < 1440) return 196 return 214 }, [viewportWidth]) @@ -343,49 +436,70 @@ export function ProjectsCarousel() { } return ( -
- - +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocusCapture={() => setPaused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setPaused(false) + } + }} + >
setPaused(true)} - onMouseLeave={() => setPaused(false)} - onFocusCapture={() => setPaused(true)} - onBlurCapture={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setPaused(false) - } + ref={trackRef} + style={{ + display: 'flex', + width: 'max-content', + willChange: 'transform', + transform: 'translate3d(0, 0, 0)', }} > -
- {[0, 1].map((setIndex) => ( -
- {investigations.map((project) => ( - openPanel({ type: 'project', investigation: project })} - /> - ))} -
- ))} -
+ {[0, 1].map((setIndex) => ( +
+ {investigations.map((project) => ( + openPanel({ type: 'project', investigation: project })} + /> + ))} +
+ ))}
) } + +// --- Main export --- + +export function ProjectsCarousel() { + const [isSmallScreen, setIsSmallScreen] = useState(() => + typeof window !== 'undefined' + ? window.matchMedia('(max-width: 1023px)').matches + : false, + ) + + useEffect(() => { + const mq = window.matchMedia('(max-width: 1023px)') + const handler = () => setIsSmallScreen(mq.matches) + setIsSmallScreen(mq.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + return ( +
+ + {isSmallScreen ? : } +
+ ) +} diff --git a/src/contexts/AccessibilityContext.tsx b/src/contexts/AccessibilityContext.tsx index 55a238d..da903b6 100644 --- a/src/contexts/AccessibilityContext.tsx +++ b/src/contexts/AccessibilityContext.tsx @@ -71,6 +71,7 @@ export function AccessibilityProvider({ children }: { children: ReactNode }) { ) } +// eslint-disable-next-line react-refresh/only-export-components export function useAccessibility() { const context = useContext(AccessibilityContext) if (!context) { diff --git a/src/contexts/DetailPanelContext.tsx b/src/contexts/DetailPanelContext.tsx index 056e7e6..b7e07ed 100644 --- a/src/contexts/DetailPanelContext.tsx +++ b/src/contexts/DetailPanelContext.tsx @@ -43,6 +43,7 @@ export function DetailPanelProvider({ children }: DetailPanelProviderProps) { ) } +// eslint-disable-next-line react-refresh/only-export-components export function useDetailPanel(): DetailPanelContextValue { const context = useContext(DetailPanelContext) if (!context) { diff --git a/src/hooks/useForceSimulation.ts b/src/hooks/useForceSimulation.ts index bc453f6..c0e6daf 100644 --- a/src/hooks/useForceSimulation.ts +++ b/src/hooks/useForceSimulation.ts @@ -621,7 +621,7 @@ export function useForceSimulation( return () => { simulation.stop() } - }, [dimensions, options]) + }, [dimensions, options, svgRef]) return { simulationRef, diff --git a/src/hooks/useTimelineAnimation.ts b/src/hooks/useTimelineAnimation.ts index 7835940..4c2b84b 100644 --- a/src/hooks/useTimelineAnimation.ts +++ b/src/hooks/useTimelineAnimation.ts @@ -408,7 +408,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) { } rafIdRef.current = requestAnimationFrame(waitForSettle) - }, [deps.simulationRef, deps.nodeSelectionRef, deps.linkSelectionRef, deps.connectorSelectionRef, deps.yearIndicatorRef, hideAll, revealStep, scheduleTimeout]) + }, [deps.simulationRef, hideAll, revealStep, scheduleTimeout]) const togglePlayPause = useCallback(() => { if (prefersReducedMotion) return diff --git a/src/index.css b/src/index.css index 6ca222c..e5a62e9 100644 --- a/src/index.css +++ b/src/index.css @@ -441,7 +441,7 @@ html { /* Tablet+: 2 columns */ @media (min-width: 768px) { .pathway-columns { - grid-template-columns: minmax(0, 2fr) minmax(0, 3.5fr); + grid-template-columns: minmax(0, 3fr) minmax(0, 3.5fr); align-items: start; gap: 22px; }