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. */}
+
+
+
+ {p.ref}
+
+ {p.location}
+
+
+
+
+ {p.name}
+
+
+ {p.scope.map((t) => (
+ -
+ {t}
+
+ ))}
+
+
{p.summary}
+
+
+
+ )
+}
+
+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)