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:
2026-09-05 22:16:42 +03:00
parent 103427f95b
commit f46131723b
31 changed files with 948 additions and 51 deletions

View File

@@ -0,0 +1,91 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { SUN_DISTANCE_Z } from './metrics';
/** Three authored streaks, batched into six triangles. No simulation or textures. */
export function createArrivalMeteors(resources: SceneResourceBag) {
const flights = [
{ x: 160, y: 335, dx: -250, dy: -100, delay: 0.1, duration: 1.8, length: 116 },
{ x: 280, y: 300, dx: -240, dy: -90, delay: 0.9, duration: 1.8, length: 95 },
{ x: 100, y: 345, dx: -205, dy: -115, delay: 1.7, duration: 1.9, length: 130 },
];
const positions: number[] = [];
const uvs: number[] = [];
const timing: number[] = [];
const travel: number[] = [];
const corners = [[0, 0], [1, 0], [0, 1], [1, 0], [1, 1], [0, 1]] as const;
for (const flight of flights) {
const distance = Math.hypot(flight.dx, flight.dy);
const dx = flight.dx / distance;
const dy = flight.dy / distance;
for (const [u, v] of corners) {
positions.push(
flight.x + (u - 1) * flight.length * dx - (v - 0.5) * 7 * dy,
flight.y + (u - 1) * flight.length * dy + (v - 0.5) * 7 * dx,
0,
);
uvs.push(u, v);
timing.push(flight.delay, flight.duration);
travel.push(flight.dx, flight.dy);
}
}
const geometry = resources.own('geometry', new THREE.BufferGeometry());
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setAttribute('aFlight', new THREE.Float32BufferAttribute(timing, 2));
geometry.setAttribute('aTravel', new THREE.Float32BufferAttribute(travel, 2));
const material = resources.own('material', new THREE.ShaderMaterial({
uniforms: { uTime: { value: -1 } },
vertexShader: `
uniform float uTime;
attribute vec2 aFlight;
attribute vec2 aTravel;
varying vec2 vUv;
varying float vLife;
void main() {
float phase = (uTime - aFlight.x) / aFlight.y;
vLife = smoothstep(0.0, 0.18, phase) * (1.0 - smoothstep(0.72, 1.0, phase));
vUv = uv;
vec3 point = position + vec3(aTravel * clamp(phase, 0.0, 1.0), 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(point, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
varying float vLife;
void main() {
float across = abs(vUv.y * 2.0 - 1.0);
float taper = pow(vUv.x, 1.4);
float core = (1.0 - smoothstep(0.06, 0.28, across)) * taper;
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
float head = (1.0 - smoothstep(0.0, 1.0,
length(vec2((vUv.x - 0.965) * 30.0, across))));
float alpha = vLife * max(glow * 0.8, head);
if (alpha < 0.005) discard;
vec3 light = mix(vec3(0.22, 0.62, 1.0), vec3(1.0, 0.94, 0.8), max(core, head));
gl_FragColor = vec4(light * 1.8, alpha);
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: true,
toneMapped: false,
}));
const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'arrival-meteors';
mesh.position.z = SUN_DISTANCE_Z + 8;
// Flight displacement lives in the shader; visibility is explicitly bounded.
mesh.frustumCulled = false;
mesh.visible = false;
let warmed = false;
return { mesh, update(age: number, active: boolean) {
// One invisible startup draw warms the exact live pipeline (including
// its post-processing color space), so arrival never compiles a variant.
const playing = active && age >= 0 && age < 4;
mesh.visible = playing || !warmed;
if (mesh.visible) material.uniforms.uTime.value = playing ? age : -1;
warmed = true;
} };
}

View File

@@ -315,6 +315,7 @@ export async function createProceduralSceneGeneration(
snapshot.routeProgress,
elapsedSeconds,
response.bloomEnergy,
snapshot.arrived,
);
car.update(
visualSpeed,

View File

@@ -18,7 +18,7 @@ export function createSignStage(resources: SceneResourceBag) {
let imageObserver: IntersectionObserver | undefined;
const headings = main
? Array.from(main.children).flatMap((panel) => {
const heading = panel.querySelector<HTMLElement>(':scope > h2');
const heading = panel.querySelector<HTMLElement>('h2');
return panel instanceof HTMLElement && heading
? [{ panel, heading }]
: [];

View File

@@ -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;