From 6373339c3f4119692ff9bd6fcefdff9587f904a5 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 3 Nov 2026 14:10:11 +0200 Subject: [PATCH] Add the stacked project deck component --- app/src/components/ProjectStack.tsx | 125 ++++++++++++++++++ app/src/hooks/useStackScrub.ts | 9 +- .../2026-11-03-mega-project-log-stack.md | 10 +- 3 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 app/src/components/ProjectStack.tsx diff --git a/app/src/components/ProjectStack.tsx b/app/src/components/ProjectStack.tsx new file mode 100644 index 0000000..ee55fe5 --- /dev/null +++ b/app/src/components/ProjectStack.tsx @@ -0,0 +1,125 @@ +import { useRef, useState, type ReactNode } from 'react' +import { img, projects, type Project } from '@/data/content' +import { useReducedMotion } from '@/hooks/useReducedMotion' +import { useStackScrub } from '@/hooks/useStackScrub' +import { LoopVideo } from './LoopVideo' +import { SectionLabel } from './Reveal' + +const INTRO = + 'Retail parks, schools, precincts and lifestyle estates across Gauteng and Limpopo, delivered with full civil services from bulk earthworks to the final surface.' + +function StackHeader() { + return ( +
+
+ + Project log + +
+

+ Five sites. One standard. +

+

{INTRO}

+
+ ) +} + +function CardFace({ p, media }: { p: Project; media: ReactNode }) { + return ( +
+ {media} + {/* Readability: heavy at the bottom for the copy, light at the top for the photo. */} +
+
+ {/* Gold glow, the Mega equivalent of the reference's ember wash. */} +
+ ) +} + +export function ProjectStack() { + const reduced = useReducedMotion() + const trackRef = useRef(null) + const cardRefs = useRef<(HTMLElement | null)[]>([]) + const [active, setActive] = useState(-1) + + useStackScrub(trackRef, cardRefs, !reduced, setActive) + + const media = (p: Project, i: number, isActive: boolean) => + p.video ? ( + 0)} + active={isActive} + className="absolute inset-0 h-full w-full object-cover" + /> + ) : ( + 0)} + alt={`${p.name}, ${p.location}`} + loading={i === 0 ? 'eager' : 'lazy'} + decoding="async" + className="absolute inset-0 h-full w-full object-cover" + /> + ) + + if (reduced) { + return ( +
+ +
+ {projects.map((p, i) => ( +
+ +
+ ))} +
+
+ ) + } + + return ( +
+
+ +
+ {projects.map((p, i) => ( +
{ + cardRefs.current[i] = el + }} + className="absolute inset-0 opacity-0 will-change-[transform,opacity]" + > + +
+ ))} +
+
+
+ ) +} diff --git a/app/src/hooks/useStackScrub.ts b/app/src/hooks/useStackScrub.ts index 146704c..411f447 100644 --- a/app/src/hooks/useStackScrub.ts +++ b/app/src/hooks/useStackScrub.ts @@ -9,12 +9,13 @@ import { cardState, timelineTotal } from '@/lib/stack' * far through the track the reader is and writes each card's transform * straight to the DOM — no React state, so scrolling never re-renders. * - * `cardRefs` must be a stable array (a `useRef` value, not a fresh literal) - * so the effect does not re-subscribe on every render. + * `cardRefs` must be a `useRef` holding the card elements, so neither the ref + * nor the array inside it changes identity between renders and the effect + * does not re-subscribe. */ export function useStackScrub( trackRef: RefObject, - cardRefs: RefObject[], + cardRefs: RefObject<(HTMLElement | null)[]>, enabled: boolean, onActive?: (index: number) => void, ) { @@ -36,7 +37,7 @@ export function useStackScrub( const apply = () => { const span = track.offsetHeight - window.innerHeight const p = span <= 0 ? 0 : Math.min(1, Math.max(0, -track.getBoundingClientRect().top / span)) - const cards = cardRefs.map((r) => r.current).filter(Boolean) as HTMLElement[] + const cards = cardRefs.current.filter(Boolean) as HTMLElement[] if (!cards.length) return const t = p * timelineTotal(cards.length) diff --git a/docs/superpowers/plans/2026-11-03-mega-project-log-stack.md b/docs/superpowers/plans/2026-11-03-mega-project-log-stack.md index 98da5fe..0d5bfed 100644 --- a/docs/superpowers/plans/2026-11-03-mega-project-log-stack.md +++ b/docs/superpowers/plans/2026-11-03-mega-project-log-stack.md @@ -369,7 +369,7 @@ git commit -m "Reshape project data to the five photographed projects" **Interfaces:** - Consumes: `cardState(index, t)`, `timelineTotal(count)` from `@/lib/stack` (Task 1). - Produces: - - `useStackScrub(trackRef: RefObject, cardRefs: RefObject[], enabled: boolean, onActive?: (index: number) => void): void` + - `useStackScrub(trackRef: RefObject, cardRefs: RefObject<(HTMLElement | null)[]>, enabled: boolean, onActive?: (index: number) => void): void` - `useReducedMotion(): boolean` - [ ] **Step 1: Write the reduced-motion hook** @@ -415,12 +415,12 @@ import { cardState, timelineTotal } from '@/lib/stack' * far through the track the reader is and writes each card's transform * straight to the DOM — no React state, so scrolling never re-renders. * - * `cardRefs` must be a stable array (a `useRef` value, not a fresh literal) - * so the effect does not re-subscribe on every render. + * `cardRefs` must hold the card elements in a `useRef` array, so the effect + * does not re-subscribe on every render. */ export function useStackScrub( trackRef: RefObject, - cardRefs: RefObject[], + cardRefs: RefObject<(HTMLElement | null)[]>, enabled: boolean, onActive?: (index: number) => void, ) { @@ -442,7 +442,7 @@ export function useStackScrub( const apply = () => { const span = track.offsetHeight - window.innerHeight const p = span <= 0 ? 0 : Math.min(1, Math.max(0, -track.getBoundingClientRect().top / span)) - const cards = cardRefs.map((r) => r.current).filter(Boolean) as HTMLElement[] + const cards = cardRefs.current.filter(Boolean) as HTMLElement[] if (!cards.length) return const t = p * timelineTotal(cards.length)