Fix project stack: opaque recede, nav clearance and intro overlap

- The receding card must stay fully opaque; dimming it let the card
  behind ghost through the title and chips on photo-led cards.
- Give the pinned stage top padding so the section label clears the
  fixed nav, and a gap so the covered card does not clip the intro.
- Let the deck size to the space left in the stage, so it can never
  overflow the viewport.
This commit is contained in:
David 2026-11-03 14:46:55 +02:00
parent d01c90265d
commit 21718e5e35
5 changed files with 31 additions and 20 deletions

View file

@ -104,20 +104,25 @@ export function ProjectStack() {
return (
<div ref={trackRef} className="relative h-[400vh]">
<div className="sticky top-0 flex h-[100svh] flex-col items-center justify-center gap-6 overflow-hidden px-5 sm:gap-8 sm:px-8 lg:px-14">
{/* The header sits clear of the fixed nav, and the deck takes the rest of
the stage, so the card can never overflow the viewport. The gap keeps
the covered card — which lifts as it recedes — off the intro copy. */}
<div className="sticky top-0 flex h-[100svh] flex-col items-center gap-6 overflow-hidden px-5 pb-10 pt-24 sm:px-8 lg:px-14">
<StackHeader />
<div className="relative h-[min(26rem,46vh)] w-[min(90vw,24rem)] sm:h-[min(30rem,54vh)] sm:w-[min(92vw,44rem)]">
{projects.map((p, i) => (
<article
key={p.ref}
ref={(el) => {
cardRefs.current[i] = el
}}
className="absolute inset-0 opacity-0 will-change-[transform,opacity]"
>
<CardFace p={p} media={media(p, i, active === i)} />
</article>
))}
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
<div className="relative h-[min(26rem,100%)] w-[min(90vw,24rem)] sm:h-[min(30rem,100%)] sm:w-[min(92vw,44rem)]">
{projects.map((p, i) => (
<article
key={p.ref}
ref={(el) => {
cardRefs.current[i] = el
}}
className="absolute inset-0 opacity-0 will-change-[transform,opacity]"
>
<CardFace p={p} media={media(p, i, active === i)} />
</article>
))}
</div>
</div>
</div>
</div>

View file

@ -44,10 +44,10 @@ test('the first card has landed after one arrival unit', () => {
assert.equal(s.rotate, 0)
})
test('a covered card eases back', () => {
test('a covered card eases back but stays opaque', () => {
const s = cardState(0, ARRIVE + RECEDE)
assert.equal(s.yPercent, -7)
assert.equal(s.opacity, 0.75)
assert.equal(s.opacity, 1)
assert.equal(s.scale, 0.92)
})

View file

@ -55,7 +55,9 @@ export function cardState(index: number, t: number): CardState {
return {
yPercent: 118 * (1 - u) - 7 * v,
opacity: u * (1 - 0.25 * v),
// The recede only shifts and shrinks the card — it must stay fully opaque.
// A translucent card would let the one behind it ghost through the copy.
opacity: u,
scale: 0.93 + 0.07 * u - 0.08 * v,
rotate: noNegativeZero(tilt * (1 - u)),
}

View file

@ -107,10 +107,10 @@ test('the first card has landed after one arrival unit', () => {
assert.equal(s.rotate, 0)
})
test('a covered card eases back', () => {
test('a covered card eases back but stays opaque', () => {
const s = cardState(0, ARRIVE + RECEDE)
assert.equal(s.yPercent, -7)
assert.equal(s.opacity, 0.75)
assert.equal(s.opacity, 1)
assert.equal(s.scale, 0.92)
})
@ -211,7 +211,9 @@ export function cardState(index: number, t: number): CardState {
return {
yPercent: 118 * (1 - u) - 7 * v,
opacity: u * (1 - 0.25 * v),
// The recede must not change opacity: a translucent card lets the card
// behind it ghost through the title and chips.
opacity: u,
scale: 0.93 + 0.07 * u - 0.08 * v,
rotate: noNegativeZero(tilt * (1 - u)),
}

View file

@ -128,13 +128,15 @@ u = power2Out(u) // 1 - (1 - x)²
v = power1InOut(v) // 2x² below 0.5, else 1 - 2(1-x)²/2
yPercent = 118 × (1 - u) - 7 × v
opacity = u × (1 - 0.25 × v)
opacity = u
scale = 0.93 + 0.07 × u - 0.08 × v
rotate = (i even ? -4° : 4°) × (1 - u)
```
`transform` is composed as `translate3d(0, yPercent%, 0) rotate(rotate) scale(scale)` and written to `element.style.transform`. `opacity` is written to `element.style.opacity`.
**The recede must not change opacity.** A covered card stays fully opaque — only its scale and position change. A translucent card would let the card behind it ghost through the title and chips, which is very visible on photo-led cards. The reference gets away with it because its cards sit on an opaque panel.
Cards later in the DOM paint above earlier ones, so the last card dealt ends up on top — the same stacking order as the reference.
### The active card