Fix mobile

This commit is contained in:
2026-02-17 23:14:05 +00:00
parent d51efb535d
commit 836305e2a3
11 changed files with 306 additions and 95 deletions
+4 -1
View File
@@ -39,7 +39,10 @@
"Bash(python scripts/generate_demo_data.py:*)", "Bash(python scripts/generate_demo_data.py:*)",
"Bash(sqlite3:*)", "Bash(sqlite3:*)",
"Bash(python:*)", "Bash(python:*)",
"Bash(grep:*)" "Bash(grep:*)",
"WebFetch(domain:www.embla-carousel.com)",
"Bash(npm ls:*)",
"Bash(npm install:*)"
] ]
} }
} }
+46
View File
@@ -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`
-...
```
+3
View File
@@ -0,0 +1,3 @@
{
"loops": []
}
+2 -2
View File
@@ -424,7 +424,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
} }
renderedLines.push( renderedLines.push(
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap"> <div key={lineIdx} className="font-mono text-sm leading-relaxed">
{spans} {spans}
</div> </div>
) )
@@ -459,7 +459,7 @@ export function BootSequence({ onComplete }: BootSequenceProps) {
) )
} }
lines.push( lines.push(
<div key={lineIdx} className="font-mono text-sm leading-relaxed whitespace-nowrap"> <div key={lineIdx} className="font-mono text-sm leading-relaxed">
{spans} {spans}
</div> </div>
) )
@@ -76,7 +76,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
const X_CHANGE_THRESHOLD = 0.3 const X_CHANGE_THRESHOLD = 0.3
const updateDimensions = () => { const updateDimensions = (force = false) => {
const width = container.clientWidth const width = container.clientWidth
const viewportWidth = window.innerWidth const viewportWidth = window.innerWidth
const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight) const height = isFullscreen ? window.innerHeight : getHeight(viewportWidth, containerHeight)
@@ -84,30 +84,59 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
? Math.max(1, Math.min(1.6, viewportWidth / 1440)) ? Math.max(1, Math.min(1.6, viewportWidth / 1440))
: 1 : 1
setDimensions(prev => { setDimensions(prev => {
const widthDelta = Math.abs(prev.width - width) / prev.width if (!force) {
const heightRatio = Math.max(height / prev.height, prev.height / height) const widthDelta = Math.abs(prev.width - width) / prev.width
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) { const heightRatio = Math.max(height / prev.height, prev.height / height)
return prev if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
return prev
}
} }
return { width, height, scaleFactor } return { width, height, scaleFactor }
}) })
} }
// Use rAF for fullscreen toggle so CSS layout settles before measuring // Force update on fullscreen/orientation change so animation always restarts
requestAnimationFrame(updateDimensions) requestAnimationFrame(() => updateDimensions(true))
const onOrientationChange = () => {
requestAnimationFrame(() => updateDimensions(true))
}
window.addEventListener('orientationchange', onOrientationChange)
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(updateDimensions, 2000) debounceTimer = setTimeout(() => updateDimensions(), 2000)
}) })
observer.observe(container) observer.observe(container)
return () => { return () => {
observer.disconnect() observer.disconnect()
window.removeEventListener('orientationchange', onOrientationChange)
if (debounceTimer) clearTimeout(debounceTimer) if (debounceTimer) clearTimeout(debounceTimer)
} }
}, [containerHeight, isFullscreen]) }, [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<void> }
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 // ESC key to exit fullscreen
useEffect(() => { useEffect(() => {
@@ -126,6 +155,17 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
return () => { document.body.style.overflow = '' } return () => { document.body.style.overflow = '' }
}, [isFullscreen]) }, [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 // Focus trap when fullscreen
useFocusTrap(containerRef, isFullscreen) useFocusTrap(containerRef, isFullscreen)
@@ -134,6 +174,8 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf) 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) 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( const resolveGraphFallback = useCallback(
() => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current, () => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
[], [],
@@ -148,6 +190,7 @@ const CareerConstellation: React.FC<CareerConstellationProps> = ({
if (pId && pType && pType !== 'skill') return pId if (pId && pType && pType !== 'skill') return pId
return null return null
}, []) }, [])
/* eslint-enable react-hooks/exhaustive-deps */
// Shared refs for hooks // Shared refs for hooks
const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null) const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
+194 -80
View File
@@ -1,4 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { investigations } from '@/data/investigations'
import { CardHeader } from '../Card' import { CardHeader } from '../Card'
import { useDetailPanel } from '@/contexts/DetailPanelContext' 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<HTMLDivElement>(null)
const [wrapperWidth, setWrapperWidth] = useState(0)
const [selectedIndex, setSelectedIndex] = useState(0)
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
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 (
<div ref={wrapperRef}>
<div ref={emblaRef} style={{ overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: '12px' }}>
{investigations.map((project) => (
<ProjectItem
key={project.id}
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
</div>
</div>
{scrollSnaps.length > 1 && (
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '6px',
marginTop: '12px',
}}
>
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to slide ${index + 1}`}
onClick={() => emblaApi?.scrollTo(index)}
style={{
width: index === selectedIndex ? '16px' : '6px',
height: '6px',
borderRadius: '3px',
border: 'none',
padding: 0,
cursor: 'pointer',
background:
index === selectedIndex
? 'var(--accent-primary, #00897B)'
: 'var(--border-light, #d1d5db)',
transition: 'all 0.3s ease',
}}
/>
))}
</div>
)}
</div>
)
}
// --- Continuous scroll carousel for screens >= 1024px ---
function ContinuousScrollCarousel() {
const { openPanel } = useDetailPanel() const { openPanel } = useDetailPanel()
const viewportRef = useRef<HTMLDivElement | null>(null) const viewportRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null) const trackRef = useRef<HTMLDivElement | null>(null)
@@ -245,95 +369,64 @@ export function ProjectsCarousel() {
useEffect(() => { useEffect(() => {
const viewportEl = viewportRef.current const viewportEl = viewportRef.current
if (!viewportEl || typeof window === 'undefined') { if (!viewportEl || typeof window === 'undefined') return
return
}
const updateWidth = () => { const updateWidth = () => {
const nextWidth = viewportEl.clientWidth const nextWidth = viewportEl.clientWidth
if (nextWidth > 0) { if (nextWidth > 0) setViewportWidth(nextWidth)
setViewportWidth(nextWidth)
}
} }
updateWidth() updateWidth()
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(() => updateWidth()) const observer = new ResizeObserver(() => updateWidth())
observer.observe(viewportEl) observer.observe(viewportEl)
return () => observer.disconnect() return () => observer.disconnect()
} }
window.addEventListener('resize', updateWidth) window.addEventListener('resize', updateWidth)
return () => window.removeEventListener('resize', updateWidth) return () => window.removeEventListener('resize', updateWidth)
}, []) }, [])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') return
return
}
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches) const syncMotionPreference = () => setPrefersReducedMotion(mediaQuery.matches)
syncMotionPreference() syncMotionPreference()
mediaQuery.addEventListener('change', syncMotionPreference) mediaQuery.addEventListener('change', syncMotionPreference)
return () => mediaQuery.removeEventListener('change', syncMotionPreference) return () => mediaQuery.removeEventListener('change', syncMotionPreference)
}, []) }, [])
useEffect(() => { useEffect(() => {
const trackEl = trackRef.current const trackEl = trackRef.current
const firstSetEl = firstSetRef.current const firstSetEl = firstSetRef.current
if (!trackEl || !firstSetEl || prefersReducedMotion) { if (!trackEl || !firstSetEl || prefersReducedMotion) return
return
}
let animationFrameId = 0 let animationFrameId = 0
let lastTime = 0 let lastTime = 0
const speedPxPerSecond = viewportWidth < 768 ? 18 : 24 const speedPxPerSecond = 24
const tick = (timestamp: number) => { const tick = (timestamp: number) => {
if (!lastTime) { if (!lastTime) lastTime = timestamp
lastTime = timestamp
}
const deltaSeconds = (timestamp - lastTime) / 1000 const deltaSeconds = (timestamp - lastTime) / 1000
lastTime = timestamp lastTime = timestamp
if (!isPausedRef.current) { if (!isPausedRef.current) {
const setWidth = firstSetEl.offsetWidth const setWidth = firstSetEl.offsetWidth
if (setWidth > 0) { if (setWidth > 0) {
offsetRef.current += speedPxPerSecond * deltaSeconds offsetRef.current += speedPxPerSecond * deltaSeconds
if (offsetRef.current >= setWidth) { if (offsetRef.current >= setWidth) offsetRef.current -= setWidth
offsetRef.current -= setWidth
}
trackEl.style.transform = `translate3d(-${offsetRef.current}px, 0, 0)` trackEl.style.transform = `translate3d(-${offsetRef.current}px, 0, 0)`
} }
} }
animationFrameId = window.requestAnimationFrame(tick) animationFrameId = window.requestAnimationFrame(tick)
} }
animationFrameId = window.requestAnimationFrame(tick) animationFrameId = window.requestAnimationFrame(tick)
return () => window.cancelAnimationFrame(animationFrameId) return () => window.cancelAnimationFrame(animationFrameId)
}, [prefersReducedMotion, viewportWidth]) }, [prefersReducedMotion, viewportWidth])
const cardsPerView = useMemo(() => {
if (viewportWidth < 480) return 1
if (viewportWidth < 768) return 2
return 4
}, [viewportWidth])
const slideWidth = useMemo(() => { const slideWidth = useMemo(() => {
const cardsPerView = 4
const gap = 12 const gap = 12
const totalGap = (cardsPerView - 1) * gap const totalGap = (cardsPerView - 1) * gap
const computedWidth = (viewportWidth - totalGap) / cardsPerView const computedWidth = (viewportWidth - totalGap) / cardsPerView
return `${Math.max(computedWidth, 0)}px` return `${Math.max(computedWidth, 0)}px`
}, [cardsPerView, viewportWidth]) }, [viewportWidth])
const cardMinHeight = useMemo(() => { const cardMinHeight = useMemo(() => {
if (viewportWidth < 480) return 148
if (viewportWidth < 640) return 168
if (viewportWidth < 1024) return 182
if (viewportWidth < 1440) return 196 if (viewportWidth < 1440) return 196
return 214 return 214
}, [viewportWidth]) }, [viewportWidth])
@@ -343,49 +436,70 @@ export function ProjectsCarousel() {
} }
return ( return (
<div style={{ marginTop: '28px' }}> <div
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" /> ref={viewportRef}
style={{ overflow: 'hidden' }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false)
}
}}
>
<div <div
ref={viewportRef} ref={trackRef}
style={{ overflow: 'hidden' }} style={{
onMouseEnter={() => setPaused(true)} display: 'flex',
onMouseLeave={() => setPaused(false)} width: 'max-content',
onFocusCapture={() => setPaused(true)} willChange: 'transform',
onBlurCapture={(event) => { transform: 'translate3d(0, 0, 0)',
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false)
}
}} }}
> >
<div {[0, 1].map((setIndex) => (
ref={trackRef} <div
style={{ key={setIndex}
display: 'flex', ref={setIndex === 0 ? firstSetRef : undefined}
width: 'max-content', style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }}
willChange: 'transform', >
transform: 'translate3d(0, 0, 0)', {investigations.map((project) => (
}} <ProjectItem
> key={`${setIndex}-${project.id}`}
{[0, 1].map((setIndex) => ( project={project}
<div slideWidth={slideWidth}
key={setIndex} cardMinHeight={cardMinHeight}
ref={setIndex === 0 ? firstSetRef : undefined} onClick={() => openPanel({ type: 'project', investigation: project })}
style={{ display: 'flex', gap: '12px', paddingRight: '12px', flexShrink: 0 }} />
> ))}
{investigations.map((project) => ( </div>
<ProjectItem ))}
key={`${setIndex}-${project.id}`}
project={project}
slideWidth={slideWidth}
cardMinHeight={cardMinHeight}
onClick={() => openPanel({ type: 'project', investigation: project })}
/>
))}
</div>
))}
</div>
</div> </div>
</div> </div>
) )
} }
// --- 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 (
<div style={{ marginTop: '28px' }}>
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
{isSmallScreen ? <EmblaProjectsCarousel /> : <ContinuousScrollCarousel />}
</div>
)
}
+1
View File
@@ -71,6 +71,7 @@ export function AccessibilityProvider({ children }: { children: ReactNode }) {
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export function useAccessibility() { export function useAccessibility() {
const context = useContext(AccessibilityContext) const context = useContext(AccessibilityContext)
if (!context) { if (!context) {
+1
View File
@@ -43,6 +43,7 @@ export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
) )
} }
// eslint-disable-next-line react-refresh/only-export-components
export function useDetailPanel(): DetailPanelContextValue { export function useDetailPanel(): DetailPanelContextValue {
const context = useContext(DetailPanelContext) const context = useContext(DetailPanelContext)
if (!context) { if (!context) {
+1 -1
View File
@@ -621,7 +621,7 @@ export function useForceSimulation(
return () => { return () => {
simulation.stop() simulation.stop()
} }
}, [dimensions, options]) }, [dimensions, options, svgRef])
return { return {
simulationRef, simulationRef,
+1 -1
View File
@@ -408,7 +408,7 @@ export function useTimelineAnimation(deps: UseTimelineAnimationDeps) {
} }
rafIdRef.current = requestAnimationFrame(waitForSettle) 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(() => { const togglePlayPause = useCallback(() => {
if (prefersReducedMotion) return if (prefersReducedMotion) return
+1 -1
View File
@@ -441,7 +441,7 @@ html {
/* Tablet+: 2 columns */ /* Tablet+: 2 columns */
@media (min-width: 768px) { @media (min-width: 768px) {
.pathway-columns { .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; align-items: start;
gap: 22px; gap: 22px;
} }