diff --git a/app/src/components/ProjectStack.tsx b/app/src/components/ProjectStack.tsx index e2d28c4..86806bc 100644 --- a/app/src/components/ProjectStack.tsx +++ b/app/src/components/ProjectStack.tsx @@ -43,7 +43,9 @@ function CardFace({ p, media }: { p: Project; media: ReactNode }) { {p.location} -
+ {/* The copy fades in on its own, so the card underneath is never + legible through the one landing on it. */} +

{p.name}

diff --git a/app/src/hooks/useStackScrub.ts b/app/src/hooks/useStackScrub.ts index 411f447..cefebec 100644 --- a/app/src/hooks/useStackScrub.ts +++ b/app/src/hooks/useStackScrub.ts @@ -46,7 +46,10 @@ export function useStackScrub( const s = cardState(i, t) card.style.transform = `translate3d(0, ${s.yPercent}%, 0) rotate(${s.rotate}deg) scale(${s.scale})` card.style.opacity = String(s.opacity) - if (s.opacity > 0) active = i + // The copy fades separately from the shell, so the incoming card does + // not sit translucent over the card it is covering. + card.style.setProperty('--copy-opacity', String(s.contentOpacity)) + if (s.contentOpacity > 0) active = i }) if (active !== activeRef.current) { diff --git a/app/src/lib/stack.test.ts b/app/src/lib/stack.test.ts index 985bcfc..16b5789 100644 --- a/app/src/lib/stack.test.ts +++ b/app/src/lib/stack.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { ARRIVE, RECEDE, STEP, cardState, clamp01, power1InOut, power2Out, timelineTotal } from './stack.ts' +import { ARRIVE, RECEDE, SHELL, STEP, cardState, clamp01, power1InOut, power2Out, timelineTotal } from './stack.ts' test('clamp01 pins values to the unit range', () => { assert.equal(clamp01(-3), 0) @@ -32,6 +32,7 @@ test('the first card starts off-stage', () => { const s = cardState(0, 0) assert.equal(s.yPercent, 118) assert.equal(s.opacity, 0) + assert.equal(s.contentOpacity, 0) assert.equal(s.scale, 0.93) assert.equal(s.rotate, -4) }) @@ -40,6 +41,7 @@ test('the first card has landed after one arrival unit', () => { const s = cardState(0, ARRIVE) assert.equal(s.yPercent, 0) assert.equal(s.opacity, 1) + assert.equal(s.contentOpacity, 1) assert.equal(s.scale, 1) assert.equal(s.rotate, 0) }) @@ -48,6 +50,7 @@ test('a covered card eases back but stays opaque', () => { const s = cardState(0, ARRIVE + RECEDE) assert.equal(s.yPercent, -7) assert.equal(s.opacity, 1) + assert.equal(s.contentOpacity, 1) assert.equal(s.scale, 0.92) }) @@ -59,7 +62,15 @@ test('a card that has not arrived stays hidden', () => { test('the next card arrives while the previous is still on screen', () => { assert.equal(cardState(0, ARRIVE).opacity, 1) - assert.ok(cardState(1, ARRIVE).opacity > 0) + assert.ok(cardState(1, ARRIVE).contentOpacity > 0) +}) + +test('the incoming card is opaque before it reaches the card it covers', () => { + // The shell must be solid while the card is still rising, or the copy + // underneath shows through it. + assert.ok(cardState(1, STEP + SHELL).opacity > 0.999) + assert.ok(cardState(1, STEP + SHELL).contentOpacity < 1) + assert.ok(cardState(1, STEP).opacity < 1) }) test('the last card is fully landed at the end of the timeline', () => { diff --git a/app/src/lib/stack.ts b/app/src/lib/stack.ts index 5b434c0..e232d22 100644 --- a/app/src/lib/stack.ts +++ b/app/src/lib/stack.ts @@ -11,6 +11,8 @@ export const ARRIVE = 1 export const RECEDE = 0.7 /** Gap between one card's arrival and the next card's arrival. */ export const STEP = 0.95 +/** Timeline units the card shell takes to become fully opaque. */ +export const SHELL = 0.2 export function clamp01(n: number): number { return n < 0 ? 0 : n > 1 ? 1 : n @@ -35,6 +37,7 @@ export function timelineTotal(count: number): number { export type CardState = { yPercent: number opacity: number + contentOpacity: number scale: number rotate: number } @@ -55,9 +58,15 @@ export function cardState(index: number, t: number): CardState { return { yPercent: 118 * (1 - u) - 7 * 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, + // The shell — the photograph and its scrim — goes opaque quickly, while + // the card is still below the frame. Fading it slowly would leave the + // incoming card translucent over the card it covers, and both sets of + // copy would be legible at the same time. + opacity: clamp01((t - arriveStart) / SHELL), + // The copy fades in over the whole arrival. + contentOpacity: u, + // The recede only shifts and shrinks the card — it must stay fully + // opaque. A translucent card lets the one behind ghost through it. scale: 0.93 + 0.07 * u - 0.08 * v, rotate: noNegativeZero(tilt * (1 - u)), }