page transition, optimize lighting

This commit is contained in:
Vincent van der Wal
2026-07-19 10:55:51 +02:00
parent f724cbff6d
commit 99c2851285
21 changed files with 388 additions and 302 deletions
+32
View File
@@ -0,0 +1,32 @@
// Preserves the orbit camera across the reload that a theme switch triggers.
// chrome.js fires thrive:store-camera right before reloading; the saved pose is
// restored (and cleared) on the next load of the same page, so regular
// navigation still gets the default framing.
const KEY = `thrive-camera:${location.pathname}`;
export function bindCameraMemory( camera, controls ) {
window.addEventListener( 'thrive:store-camera', () => {
try {
sessionStorage.setItem( KEY, JSON.stringify( {
position: camera.position.toArray(),
target: controls.target.toArray(),
} ) );
} catch {
// storage unavailable: the camera just resets
}
} );
let saved = null;
try {
saved = JSON.parse( sessionStorage.getItem( KEY ) );
sessionStorage.removeItem( KEY );
} catch {
saved = null;
}
if ( saved ) {
camera.position.fromArray( saved.position );
controls.target.fromArray( saved.target );
controls.update();
}
}
+20 -25
View File
@@ -1,38 +1,30 @@
import { currentTheme, toggleTheme } from './theme.js';
import { EXIT_MS } from './transit.js';
// Shared page chrome: theme attribute for the CSS, fade transitions between
// pages, and the dark/light toggle. Imported for its side effects by every page.
// Shared page chrome: theme attribute for the CSS, page transitions, and the
// dark/light toggle. Imported for its side effects by every page.
//
// Navigation choreography: the shapes fly out first (thrive:exit), then the
// browser's cross-document view transition (see @view-transition in the CSS)
// crossfades the last frame into the next page, whose shapes fly in. No
// blocking overlay - pages blend into each other.
document.documentElement.dataset.theme = currentTheme();
const FADE_MS = 350;
window.addEventListener( 'DOMContentLoaded', () => {
// the overlay lives in the HTML templates so it covers the very first paint;
// pages without one (shouldn't happen) still get a working fallback
let fade = document.querySelector( '.fade' );
if ( ! fade ) {
fade = document.createElement( 'div' );
fade.className = 'fade';
document.body.appendChild( fade );
}
// double rAF so the browser commits the opaque state before transitioning
requestAnimationFrame( () => requestAnimationFrame( () => fade.classList.add( 'done' ) ) );
// fly the shapes out first, fade through the background for the tail of the
// flight, then navigate
const fadeTo = ( action ) => {
// shapes fly out and the page content fades (html.leaving) at the same
// time; navigation happens mid-flight so the document crossfade blends
// moving frames instead of an empty pause
const leaveTo = ( action ) => {
window.dispatchEvent( new Event( 'thrive:exit' ) );
setTimeout( () => fade.classList.remove( 'done' ), EXIT_MS - FADE_MS + 100 );
setTimeout( action, EXIT_MS + 100 );
document.documentElement.classList.add( 'leaving' );
setTimeout( action, EXIT_MS * 0.75 );
};
document.addEventListener( 'click', ( event ) => {
const link = event.target.closest( 'a[href]' );
if ( ! link || link.origin !== location.origin ) return;
event.preventDefault();
fadeTo( () => { location.href = link.href; } );
leaveTo( () => { location.href = link.href; } );
} );
const button = document.createElement( 'button' );
@@ -41,11 +33,14 @@ window.addEventListener( 'DOMContentLoaded', () => {
button.title = 'toggle dark / light';
button.addEventListener( 'click', () => {
// the scenes build their materials once at startup, so switching theme
// reloads the page - the fade makes that read as a transition
fadeTo( () => {
// reloads the page behind the same fly-out + crossfade; the camera pose
// is stashed so the reload comes back to the same view
window.dispatchEvent( new Event( 'thrive:store-camera' ) );
leaveTo( () => {
toggleTheme();
location.reload();
} );
} );
document.body.appendChild( button );
// scene pages carry the toggle in the top nav; the landing gets it floating
( document.querySelector( '.page-nav' ) || document.body ).appendChild( button );
} );
+35
View File
@@ -0,0 +1,35 @@
import * as THREE from 'three';
// Keeps the gradient's bright spot gravitating toward the projection wall as
// the camera orbits: the wall centre (x = -10 in every scene) is projected into
// screen space each frame and eased into the CSS gradient position.
const WALL_CENTRE = new THREE.Vector3( -10, 0, 0 );
export function bindGlow( camera ) {
const projected = new THREE.Vector3();
let x = 12;
let y = 50;
let writtenX = null;
let writtenY = null;
return function update() {
projected.copy( WALL_CENTRE ).project( camera );
// when the wall goes behind the camera the projection flips sides;
// leave the glow where it last was until the wall swings back
if ( projected.z < 1 ) {
const targetX = THREE.MathUtils.clamp( ( projected.x + 1 ) * 50, -40, 140 );
const targetY = THREE.MathUtils.clamp( ( 1 - projected.y ) * 50, -40, 140 );
x += ( targetX - x ) * 0.08;
y += ( targetY - y ) * 0.08;
}
// writing the property repaints the whole gradient, so only touch the
// DOM while the glow is actually moving
if ( writtenX !== null && Math.abs( x - writtenX ) < 0.05 && Math.abs( y - writtenY ) < 0.05 ) return;
writtenX = x;
writtenY = y;
document.body.style.setProperty( '--glow-x', `${x.toFixed( 2 )}%` );
document.body.style.setProperty( '--glow-y', `${y.toFixed( 2 )}%` );
};
}
+5
View File
@@ -5,6 +5,8 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import './chrome.js';
import { sceneColors } from './theme.js';
import { bindWorld } from './transit.js';
import { bindGlow } from './glow.js';
import { bindCameraMemory } from './cameraMemory.js';
// Shared scene setup for the exploration pages: the landing page gradient shows
// through a transparent canvas, a side light throws a light orange shadow onto
@@ -35,6 +37,8 @@ export function createStage( { target = [ 0, 0, 0 ] } = {} ) {
const controls = new OrbitControls( camera, renderer.domElement );
controls.target.set( ...target );
bindCameraMemory( camera, controls );
const updateGlow = bindGlow( camera );
// dark mode gets a soft warm ambient so unlit faces keep their colour
if ( colors.dark ) scene.add( new THREE.AmbientLight( colors.light, 0.9 ) );
@@ -78,6 +82,7 @@ export function createStage( { target = [ 0, 0, 0 ] } = {} ) {
for ( const update of updates ) update( time );
updateWorld();
updateGlow();
controls.update();
renderer.render( scene, camera );
}
+2 -2
View File
@@ -29,6 +29,6 @@ const fills = {
export function sceneColors() {
return currentTheme() === 'dark'
? { dark: true, light: 0xffb37a, line: 0xf5e9de, shadow: 0xff9a5c, fills }
: { dark: false, light: 0xffb37a, line: 0x1b0a02, shadow: 0xf44f04, fills };
? { dark: true, light: 0xffb37a, line: 0x1b0a02, shadow: 0xffb385, fills }
: { dark: false, light: 0xffb37a, line: 0xffffff, shadow: 0xd94403, fills };
}
+5 -4
View File
@@ -9,8 +9,9 @@ const easeOutCubic = ( t ) => 1 - Math.pow( 1 - t, 3 );
const easeInCubic = ( t ) => t * t * t;
// wraps a THREE.Group holding all of a page's shapes; call the returned
// function every frame
export function bindWorld( world ) {
// function every frame. baseScale is the group's resting scale, for pages whose
// raw geometry is smaller than the others.
export function bindWorld( world, baseScale = 1 ) {
let mode = 'enter';
let start = performance.now();
@@ -22,10 +23,10 @@ export function bindWorld( world ) {
return function update() {
if ( mode === 'enter' ) {
const k = Math.min( 1, ( performance.now() - start ) / ENTER_MS );
world.scale.setScalar( Math.max( 0.001, easeOutCubic( k ) ) );
world.scale.setScalar( baseScale * Math.max( 0.001, easeOutCubic( k ) ) );
} else {
const k = Math.min( 1, ( performance.now() - start ) / EXIT_MS );
world.scale.setScalar( 1 + easeInCubic( k ) * 7 );
world.scale.setScalar( baseScale * ( 1 + easeInCubic( k ) * 7 ) );
}
};
}