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)),
}