Fix mobile
This commit is contained in:
@@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
-...
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"loops": []
|
||||||
|
}
|
||||||
@@ -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 => {
|
||||||
|
if (!force) {
|
||||||
const widthDelta = Math.abs(prev.width - width) / prev.width
|
const widthDelta = Math.abs(prev.width - width) / prev.width
|
||||||
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
const heightRatio = Math.max(height / prev.height, prev.height / height)
|
||||||
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
if (widthDelta < X_CHANGE_THRESHOLD && heightRatio < 2) {
|
||||||
return prev
|
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)
|
||||||
|
|||||||
@@ -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,9 +436,6 @@ export function ProjectsCarousel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: '28px' }}>
|
|
||||||
<CardHeader dotColor="amber" title="SIGNIFICANT INTERVENTIONS" />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
style={{ overflow: 'hidden' }}
|
style={{ overflow: 'hidden' }}
|
||||||
@@ -386,6 +476,30 @@ export function ProjectsCarousel() {
|
|||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ export function useForceSimulation(
|
|||||||
return () => {
|
return () => {
|
||||||
simulation.stop()
|
simulation.stop()
|
||||||
}
|
}
|
||||||
}, [dimensions, options])
|
}, [dimensions, options, svgRef])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
simulationRef,
|
simulationRef,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user