diff --git a/docs/redesign/round5/outro-after-1440.webp b/docs/redesign/round5/outro-after-1440.webp new file mode 100644 index 0000000..d48d8b1 Binary files /dev/null and b/docs/redesign/round5/outro-after-1440.webp differ diff --git a/docs/redesign/round5/outro-before-1440.webp b/docs/redesign/round5/outro-before-1440.webp new file mode 100644 index 0000000..bb19c79 Binary files /dev/null and b/docs/redesign/round5/outro-before-1440.webp differ diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index ed57ee1..ad26b60 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -246,6 +246,7 @@ onUnmounted(() => removePageListeners()); :policy="motion.policy.value" :quality="actualQuality" :scene-description="content.sceneDescription" + :credit="`${content.identity.name} · ${content.identity.role} · ${content.identity.location.city}, ${content.identity.location.country}`" :sample-frame="sampleSceneFrame" @state-change="updateSceneState" /> diff --git a/src/components/SceneCanvas.vue b/src/components/SceneCanvas.vue index cd810f5..1332e6d 100644 --- a/src/components/SceneCanvas.vue +++ b/src/components/SceneCanvas.vue @@ -15,6 +15,8 @@ const props = defineProps<{ policy: MotionPolicy; quality: QualityTier; sceneDescription: string; + /** Closing credit for the destination letterbox: real identity facts only. */ + credit?: string; sampleFrame?: (start: number, end: number) => unknown; }>(); @@ -55,6 +57,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure'); :data-static-fallback="!showCanvas" :data-poster-failed="posterFailed" :data-arrived="journey.arrived" + :data-credit="credit" > diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts index c52d756..6f8f6e4 100644 --- a/src/scene/world/environment.ts +++ b/src/scene/world/environment.ts @@ -184,6 +184,9 @@ export async function createProceduralSceneGeneration( let introProgress: number | null = null; let introFrames = 0; let introSlowFrames = 0; + // The outro mirrors the intro: on arrival the camera cranes back up toward + // the sun on a long lens. Leaving the destination reverses it, faster. + let outroProgress = 0; let gantryState = ''; const resize = (): void => { @@ -515,9 +518,18 @@ export async function createProceduralSceneGeneration( if (introSlowFrames >= 3) introProgress = 1; introProgress = Math.min(1, introProgress + deltaSeconds / INTRO_SECONDS); } + outroProgress = THREE.MathUtils.clamp( + outroProgress + + (snapshot.arrived ? deltaSeconds / OUTRO_SECONDS : -deltaSeconds / OUTRO_RETURN_SECONDS), + 0, + 1, + ); + // Part of the way back, not all: the end shot holds the sun, the + // skyline and the road running into them, like a film's last frame. + const outro = easeInOutCubic(outroProgress) * OUTRO_REACH; const crane = easeInOutCubic( THREE.MathUtils.clamp((introProgress - 0.06) / 0.94, 0, 1), - ); + ) * (1 - outro); const craneShift = cameraState.shift * crane; camera.setViewOffset( width, @@ -597,6 +609,9 @@ export async function createProceduralSceneGeneration( } const INTRO_SECONDS = 4.2; +const OUTRO_SECONDS = 5.5; +const OUTRO_RETURN_SECONDS = 1.6; +const OUTRO_REACH = 0.62; function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; diff --git a/src/styles/world.css b/src/styles/world.css index 8257e25..f328a8d 100644 --- a/src/styles/world.css +++ b/src/styles/world.css @@ -504,6 +504,76 @@ } } +/* Round 5 outro: letterbox bars close in on arrival, on every tier (they are + CSS, so the static poster gets them too). They sit in the world layer, + under the reading column, so they frame the sky and never cover content. + The upper bar hangs below the gantry and carries a closing credit built + from real identity facts; the lower bar is left clear for the trip + summary that ends the contact chapter. */ +.journey-world::before, +.journey-world::after { + position: absolute; + z-index: 1; + inset-inline: 0; + block-size: max(54px, 10vh); + background: #020108; + content: ''; + transform: scaleY(0); + transition: transform 1.6s cubic-bezier(0.65, 0, 0.35, 1); +} +.journey-world::before { + inset-block-start: 0; + display: grid; + align-items: end; + justify-items: center; + block-size: calc(var(--gantry-h, 72px) + max(30px, 4.5vh)); + padding: 0 16px max(8px, 1.2vh); + color: rgb(255 226 196 / 0%); + font: 600 0.6rem/1.3 var(--mono, ui-monospace, monospace); + letter-spacing: 0.32em; + text-align: center; + text-transform: uppercase; + white-space: nowrap; + content: attr(data-credit) / ''; + transform-origin: top; + transition: + transform 1.6s cubic-bezier(0.65, 0, 0.35, 1), + color 1.2s ease; +} +.journey-world::after { + inset-block-end: 0; + transform-origin: bottom; +} +.journey-world[data-arrived='true']::before, +.journey-world[data-arrived='true']::after { + transform: scaleY(1); + transition-delay: 0.5s; +} +.journey-world[data-arrived='true']::before { + color: rgb(255 226 196 / 74%); + transition-delay: 0.5s, 1.9s; +} +@media (max-width: 767px) { + .journey-world::after { block-size: max(40px, 7vh); } + .journey-world::before { + font-size: 0.5rem; + letter-spacing: 0.14em; + white-space: normal; + } +} +@media (prefers-reduced-motion: reduce) { + .journey-world::before, + .journey-world::after, + .journey-world[data-arrived='true']::before, + .journey-world[data-arrived='true']::after { + transition: none; + } +} +@media (forced-colors: active) { + .journey-world::before, + .journey-world::after { display: none; } +} + @media (prefers-reduced-motion: reduce) { .journey-poster { filter: saturate(0.86) brightness(0.92);