diff --git a/app/components/Partners.tsx b/app/components/Partners.tsx index b0d78f0..51a7202 100644 --- a/app/components/Partners.tsx +++ b/app/components/Partners.tsx @@ -11,7 +11,7 @@ export type Partner = { const partners: Partner[] = [ { name: "Applications in CADD", initials: "AC", logo: "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/partners/appscadd.png", url: "https://appsincadd.co.uk/" }, - { name: "Synera", initials: "SY", logo: "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/partners/synera.png", url: "https://www.synera.io/", logoTone: "invert" }, + { name: "Synera", initials: "SY", logo: "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/partners/synera.png", url: "https://www.synera.ai/", logoTone: "invert" }, { name: "Ditt Officemakers", initials: "DT", logo: "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/partners/ditt.png", url: "https://www.ditt.nl/" }, { name: "Imverse", initials: "IM", logo: "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/partners/imverse.png", url: "https://www.imverse.com/" }, { name: "Wild Software Inc", initials: "WI", logo: "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/partners/wild.jpg", url: "https://factionsvr.com/" }, diff --git a/app/components/ProjectsSection.tsx b/app/components/ProjectsSection.tsx index f816d7d..065b972 100644 --- a/app/components/ProjectsSection.tsx +++ b/app/components/ProjectsSection.tsx @@ -115,7 +115,7 @@ const projects: Project[] = [ { slug: "synera", company: "Synera", - url: "https://www.synera.io/", + url: "https://www.synera.ai/", title: "Renderer Performance Analysis & Design", summary: "Performance diagnosis and renderer architecture planning for complex engineering scenes.", bullets: [ diff --git a/app/components/TestimonialsSection.tsx b/app/components/TestimonialsSection.tsx index 1463523..cf2efa3 100644 --- a/app/components/TestimonialsSection.tsx +++ b/app/components/TestimonialsSection.tsx @@ -1,4 +1,7 @@ +"use client"; + import Image from "next/image"; +import { useCallback, useEffect, useRef, useState } from "react"; type Testimonial = { name: string; @@ -6,6 +9,7 @@ type Testimonial = { profileUrl: string; profilePicture: string; companyIcon: string; + companyIconTone?: "normal" | "invert"; testimonial: string; }; @@ -28,9 +32,242 @@ const testimonials: Testimonial[] = [ testimonial: "The DevSH team transformed our CPU render farm for architectural visualization into a scalable GPU-based system with outstanding performance and reliability. Their expertise and commitment to achieving the best results were clear throughout the project. They maintained full backward compatibility, ensuring a smooth transition with minimal disruption to our workflows.", }, + { + name: "Sven Seele", + role: "Techlead at Synera", + profileUrl: "https://www.linkedin.com/in/sven-seele-09433a41/", + profilePicture: "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/testimonials/sven.jpg", + companyIcon: "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/partners/synera.png", + companyIconTone: "invert", + testimonial: + "We were looking for performance improvements for our integrated viewer technology and DevSH brought exactly the expertise we needed. They worked independently, asked the right questions, and delivered a report that was thorough and easy to follow, with the required performance analysis and concrete improvement strategies. They also gave us sound technical guidance on rendering strategy that helped align our team internally. We'd definitely work with them again.", + }, ]; +// Continuous drift of the desktop carousel, in pixels per second. +const DRIFT_SPEED = 16; +// How sharply an arrow press is eased into the drift; higher settles faster. +const NUDGE_DAMPING = 6; +// Halt the drift while the pointer is anywhere over the carousel, so a quote +// can be read without it sliding away. The arrow controls pause regardless of +// this flag; see the note on them below. +const PAUSE_ON_HOVER = true; + +function useMediaQuery(query: string) { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mql = window.matchMedia(query); + const update = () => setMatches(mql.matches); + + update(); + mql.addEventListener("change", update); + return () => mql.removeEventListener("change", update); + }, [query]); + + return matches; +} + +function TestimonialCard({ + testimonial, + clone, +}: { + testimonial: Testimonial; + clone?: boolean; +}) { + return ( +
+
+
+
+ {clone +
+ +
+ +

{testimonial.name}

+ +
+

{testimonial.role}

+
+
+ +
+ +
+
+ +

+ “{testimonial.testimonial}” +

+
+ ); +} + export default function TestimonialsSection() { + const trackRef = useRef(null); + const viewportRef = useRef(null); + // Current scroll position of the track, and any pending arrow movement + // still being eased in. Both live in refs so the rAF loop can drive the + // transform directly instead of re-rendering every frame. + const offsetRef = useRef(0); + const nudgeRef = useRef(0); + const cycleRef = useRef(0); + const centredRef = useRef(false); + const [paused, setPaused] = useState(false); + + const isDesktop = useMediaQuery("(min-width: 64rem)"); + const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + + const trackGap = useCallback((track: HTMLDivElement) => { + return parseFloat(getComputedStyle(track).columnGap || "0") || 0; + }, []); + + // The track renders the list twice, so one full cycle is half its width. + // scrollWidth omits the trailing gap, hence the correction. + const measure = useCallback(() => { + const track = trackRef.current; + if (!track) return; + + const cycle = (track.scrollWidth + trackGap(track)) / 2; + cycleRef.current = cycle; + + // Open with the first quote centred rather than flush against the left + // edge. Done once per activation so a resize does not yank the track back + // to the start mid-drift. + if (centredRef.current || cycle <= 0) return; + + const viewport = viewportRef.current; + const card = track.querySelector(".testimonial-quote"); + if (!viewport || !card) return; + + const start = card.offsetWidth / 2 - viewport.clientWidth / 2; + offsetRef.current = ((start % cycle) + cycle) % cycle; + track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + centredRef.current = true; + }, [trackGap]); + + useEffect(() => { + if (!isDesktop) { + // Leaving desktop: drop the inline transform so the stacked layout is + // clean, and re-centre if the carousel comes back. + offsetRef.current = 0; + nudgeRef.current = 0; + centredRef.current = false; + if (trackRef.current) trackRef.current.style.transform = ""; + return; + } + + measure(); + const track = trackRef.current; + const observer = new ResizeObserver(measure); + if (track) observer.observe(track); + + return () => observer.disconnect(); + }, [isDesktop, measure]); + + useEffect(() => { + if (!isDesktop) return; + + let frame = 0; + let last = performance.now(); + + const tick = (now: number) => { + // Clamp dt so a backgrounded tab does not jump the track on return. + const dt = Math.min((now - last) / 1000, 0.05); + last = now; + + const cycle = cycleRef.current; + if (cycle > 0) { + const drift = paused || reducedMotion ? 0 : DRIFT_SPEED * dt; + + // Exponential easing approaches zero without reaching it, so close the + // final sub-pixel outright and let the card settle exactly centred. + let catchUp = nudgeRef.current * (1 - Math.exp(-dt * NUDGE_DAMPING)); + if (Math.abs(nudgeRef.current - catchUp) < 0.5) { + catchUp = nudgeRef.current; + } + nudgeRef.current -= catchUp; + + const next = offsetRef.current + drift + catchUp; + offsetRef.current = ((next % cycle) + cycle) % cycle; + + const track = trackRef.current; + if (track) { + track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + } + } + + frame = requestAnimationFrame(tick); + }; + + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [isDesktop, paused, reducedMotion]); + + // Move to the adjacent card and land it dead centre in the viewport, rather + // than shifting by a fixed amount and inheriting whatever fractional + // position the drift happened to leave behind. + const goTo = useCallback( + (direction: 1 | -1) => { + const track = trackRef.current; + const viewport = viewportRef.current; + const card = track?.querySelector(".testimonial-quote"); + if (!track || !viewport || !card) return; + + const stride = card.offsetWidth + trackGap(track); + if (stride <= 0) return; + + // Offset at which card `index` is centred. + const centreFor = (index: number) => + index * stride + card.offsetWidth / 2 - viewport.clientWidth / 2; + + // Include any movement still easing in, so rapid presses queue up + // instead of fighting each other. + const pending = offsetRef.current + nudgeRef.current; + const current = (pending - centreFor(0)) / stride; + + // Land on the first card boundary strictly past the current position, so + // a press never skips a card mid-drift and never stalls when one is + // already centred. The epsilon is well under a pixel of stride. + const target = + direction === 1 + ? Math.floor(current + 1e-3) + 1 + : Math.ceil(current - 1e-3) - 1; + + nudgeRef.current += centreFor(target) - pending; + }, + [trackGap], + ); + return (
@@ -38,59 +275,79 @@ export default function TestimonialsSection() {

What our partners say

-
- {testimonials.map((testimonial) => ( -
-
-
-
- {testimonial.name} -
- -
- -

- {testimonial.name} -

- -
-

- {testimonial.role} -

-
-
- -
- setPaused(true) : undefined} + onMouseLeave={PAUSE_ON_HOVER ? () => setPaused(false) : undefined} + > +
+
+ {testimonials.map((testimonial) => ( + + ))} + {/* The second set only exists to close the desktop loop. It is + rendered after mount so mobile never ships a duplicate copy, + and so the server and first client render agree. */} + {isDesktop && + testimonials.map((testimonial) => ( + -
-
- -

- “{testimonial.testimonial}” -

-
- ))} + ))} +
+ + + {/* The controls pause regardless of PAUSE_ON_HOVER: stepping is + pointless if the drift immediately carries the card away. Hover + is covered as well as focus because Safari does not focus a + button on click. */} +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocusCapture={() => setPaused(true)} + onBlurCapture={() => setPaused(false)} + > + + +
diff --git a/app/globals.css b/app/globals.css index c1db918..d40fb23 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1687,12 +1687,24 @@ body { position: relative; } - .testimonial-quotes { - display: grid; - align-items: start; + .testimonial-carousel { + position: relative; + } + + .testimonial-viewport { + overflow: hidden; + } + + .testimonial-track { + display: flex; + flex-direction: column; gap: clamp(1.25rem, 2.4vw, 2rem); } + .testimonial-controls { + display: none; + } + .testimonial-quote { position: relative; display: flex; @@ -1833,14 +1845,78 @@ body { } @media (min-width: 64rem) { - .testimonial-quotes { - grid-template-columns: repeat(2, minmax(0, 1fr)); + /* Fade the edges so cards enter and leave the loop instead of being cut. */ + .testimonial-viewport { + --testimonial-fade: clamp(2rem, 5vw, 5rem); + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + #000 var(--testimonial-fade), + #000 calc(100% - var(--testimonial-fade)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + transparent 0, + #000 var(--testimonial-fade), + #000 calc(100% - var(--testimonial-fade)), + transparent 100% + ); + } + + .testimonial-track { + flex-direction: row; + align-items: stretch; gap: clamp(2.75rem, 5vw, 4.5rem); + will-change: transform; } .testimonial-quote { + width: clamp(26rem, 33vw, 34rem); + flex: 0 0 auto; padding-inline: clamp(0.75rem, 1.6vw, 1.35rem); } + + .testimonial-controls { + display: flex; + justify-content: center; + gap: 0.85rem; + margin-top: clamp(1.75rem, 3vw, 2.75rem); + } + + .testimonial-arrow { + display: grid; + place-items: center; + width: 2.9rem; + aspect-ratio: 1; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: rgb(210 218 218); + cursor: pointer; + transition: + background-color 220ms ease, + border-color 220ms ease, + color 220ms ease, + transform 220ms ease; + } + + .testimonial-arrow svg { + width: 1.15rem; + height: 1.15rem; + } + + .testimonial-arrow:hover { + border-color: rgba(125, 205, 185, 0.6); + background: rgba(125, 205, 185, 0.14); + color: #fff; + transform: translateY(-0.08rem); + } + + .testimonial-arrow:focus-visible { + outline: 2px solid rgba(125, 205, 185, 0.68); + outline-offset: 0.24rem; + } } @media (max-width: 39.98rem) { diff --git a/public/testimonials/sven.jpg b/public/testimonials/sven.jpg new file mode 100644 index 0000000..ab4511d Binary files /dev/null and b/public/testimonials/sven.jpg differ