feat: finish portfolio title, navigation, and destination effects
Add the approved name animation, rounded docking navigation, localized keyboard focus cue, unified builds card, and destination copy hierarchy.
Introduce lightweight one-shot meteors and a sun ring that repeats every three seconds at Contact. Open the email action in a separate browsing context. Preserve reduced-motion behavior and keep private handoff and visual-check artifacts out of version control.
Validation: 649 tests passed; all 20 failures in world-models and SceneCanvas reproduce on baseline 103427f. Typecheck, artifact verification, and transfer budgets passed. Client/SSR preview build passed before this commit. Existing baseline test failures remain unchanged.
This commit is contained in:
@@ -3,6 +3,7 @@ import type { SceneResourceBag } from '@/scene/resources';
|
||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||
import { SUN_DISTANCE_Z } from '@/scene/world/metrics';
|
||||
import { createArrivalMeteors } from './arrival-meteors';
|
||||
|
||||
export interface SunWorld {
|
||||
group: WorldGroup;
|
||||
@@ -11,6 +12,7 @@ export interface SunWorld {
|
||||
routeProgress: number,
|
||||
elapsedSeconds: number,
|
||||
driveEnergy?: number,
|
||||
arrived?: boolean,
|
||||
): void;
|
||||
}
|
||||
|
||||
@@ -24,6 +26,9 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
group.name = 'sun-world';
|
||||
const night = CHAPTER_PALETTES['deep-night'];
|
||||
const sunColor = new THREE.Color(night.sun);
|
||||
const arrivalColor = new THREE.Color(0xffead0);
|
||||
let wasArrived = false;
|
||||
let arrivalStartedAt = 0;
|
||||
const uniforms = {
|
||||
uColor: { value: sunColor },
|
||||
uHazeColor: { value: new THREE.Color(night.haze) },
|
||||
@@ -32,6 +37,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uRouteProgress: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uDriveEnergy: { value: 0 },
|
||||
uArrival: { value: 0 },
|
||||
};
|
||||
const geometry = resources.own(
|
||||
'geometry',
|
||||
@@ -57,6 +63,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uniform float uRouteProgress;
|
||||
uniform float uTime;
|
||||
uniform float uDriveEnergy;
|
||||
uniform float uArrival;
|
||||
varying vec2 vSunUv;
|
||||
|
||||
void main() {
|
||||
@@ -73,9 +80,9 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
|
||||
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
|
||||
* sin(solar.y * 31.0 - uTime * 0.032);
|
||||
vec3 vermilion = vec3(0.85, 0.018, 0.28);
|
||||
vec3 vermilion = mix(vec3(0.85, 0.018, 0.28), vec3(1.0, 0.24, 0.12), uArrival);
|
||||
vec3 coral = mix(uColor, vec3(1.0, 0.16, 0.13), 0.48);
|
||||
vec3 crown = vec3(1.0, 0.63, 0.24);
|
||||
vec3 crown = mix(vec3(1.0, 0.63, 0.24), vec3(1.0, 0.88, 0.64), uArrival);
|
||||
vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
|
||||
core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
|
||||
float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
|
||||
@@ -107,6 +114,10 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
uniforms: {
|
||||
uColor: { value: sunColor.clone() },
|
||||
uEnergy: { value: 0 },
|
||||
uScale: { value: 1 },
|
||||
uRingRadius: { value: 0.49 },
|
||||
uRingOpacity: { value: 0 },
|
||||
uArrival: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
@@ -118,12 +129,18 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
uniform float uEnergy;
|
||||
uniform float uScale;
|
||||
uniform float uRingRadius;
|
||||
uniform float uRingOpacity;
|
||||
uniform float uArrival;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
float radius = distance(vUv, vec2(0.5));
|
||||
float radius = distance(vUv, vec2(0.5)) * uScale;
|
||||
float ring = smoothstep(0.477, 0.488, radius)
|
||||
* (1.0 - smoothstep(0.491, 0.499, radius));
|
||||
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
|
||||
float echo = (1.0 - smoothstep(0.003, 0.009, abs(radius - uRingRadius))) * uRingOpacity;
|
||||
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07 + uArrival * 0.55),
|
||||
ring * (0.22 + uEnergy * 0.025 + uArrival * 0.2) + echo * 0.9);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
@@ -181,27 +198,49 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
||||
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
|
||||
bleed.scale.set(560, 118, 1);
|
||||
group.add(bleed);
|
||||
const meteors = createArrivalMeteors(resources);
|
||||
group.add(meteors.mesh);
|
||||
|
||||
return {
|
||||
group,
|
||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0) {
|
||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false) {
|
||||
const progress = clamp01(routeProgress);
|
||||
const energy = clamp01(driveEnergy);
|
||||
sunColor.copy(palette.sun);
|
||||
const time = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
|
||||
if (arrived && !wasArrived) arrivalStartedAt = time;
|
||||
wasArrived = arrived;
|
||||
const arrivalAge = time - arrivalStartedAt;
|
||||
const arrivalGlow = arrived ? clamp01(arrivalAge / 1.2) : 0;
|
||||
const arrivalPhase = clamp01((arrivalAge - 0.5) / 3.3);
|
||||
// Meteors and the broad halo swell remain a one-shot arrival finale.
|
||||
const arrivalPulse = arrived && arrivalPhase > 0 && arrivalPhase < 1
|
||||
? Math.sin(Math.PI * arrivalPhase) ** 2
|
||||
: 0;
|
||||
// Only the rim repeats, using the existing visibility-aware scene clock.
|
||||
const ringPhase = (Math.max(0, arrivalAge - 0.5) % 3) / 3;
|
||||
const ringPulse = arrived ? Math.sin(Math.PI * ringPhase) ** 2 : 0;
|
||||
meteors.update(arrivalAge, arrived);
|
||||
sunColor.copy(palette.sun).lerp(arrivalColor, arrivalGlow * 0.55);
|
||||
uniforms.uHazeColor.value.copy(palette.haze);
|
||||
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
||||
haloMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22)
|
||||
.lerp(arrivalColor, arrivalGlow * 0.7);
|
||||
bleedMaterial.color.copy(palette.sun).lerp(palette.haze, 0.22);
|
||||
limbMaterial.uniforms.uColor.value.copy(palette.sun);
|
||||
limbMaterial.uniforms.uColor.value.copy(sunColor);
|
||||
uniforms.uWarmth.value = clamp01(palette.warmth);
|
||||
uniforms.uRouteProgress.value = progress;
|
||||
uniforms.uTime.value = Number.isFinite(elapsedSeconds)
|
||||
? elapsedSeconds
|
||||
: 0;
|
||||
uniforms.uTime.value = time;
|
||||
uniforms.uDriveEnergy.value = energy;
|
||||
uniforms.uArrival.value = arrivalGlow;
|
||||
limbMaterial.uniforms.uEnergy.value = energy;
|
||||
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015;
|
||||
const ringScale = ringPulse > 0 ? 1.9 : 1;
|
||||
limb.scale.setScalar(1.034 * ringScale);
|
||||
limbMaterial.uniforms.uScale.value = ringScale;
|
||||
limbMaterial.uniforms.uRingRadius.value = 0.49 + ringPhase * 0.36;
|
||||
limbMaterial.uniforms.uRingOpacity.value = ringPulse;
|
||||
limbMaterial.uniforms.uArrival.value = arrivalGlow;
|
||||
haloMaterial.opacity = 0.095 + palette.warmth * 0.03 + energy * 0.015 + arrivalGlow * 0.08 + arrivalPulse * 0.1;
|
||||
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
|
||||
halo.scale.set(480 + energy * 18, 360 + energy * 10, 1);
|
||||
halo.scale.set(480 + energy * 18 + arrivalGlow * 40 + arrivalPulse * 76, 360 + energy * 10 + arrivalGlow * 30 + arrivalPulse * 56, 1);
|
||||
disc.position.y = 79 + palette.warmth * 14;
|
||||
limb.position.y = disc.position.y;
|
||||
halo.position.y = disc.position.y;
|
||||
|
||||
Reference in New Issue
Block a user