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

@@ -9,6 +9,7 @@ import './styles/world.css';
import './styles/evolution.css';
import './styles/chromatic.css';
import './styles/refinements.css';
import './styles/title-navigation.css';
import './styles/print.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();

View File

@@ -219,6 +219,7 @@ onUnmounted(() => removePageListeners());
<SiteNavigation
:items="canonicalNavigation"
:journey="journey"
:allow-motion="motion.policy.value.allowSceneRequest"
:on-section-link-click="navigation.onSectionLinkClick"
/>
</header>
@@ -226,7 +227,7 @@ onUnmounted(() => removePageListeners());
<div class="reading-stage">
<div class="reading-viewport">
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
<HeroJourney :identity="content.identity" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<p>{{ content.profile.introduction }}</p>
@@ -247,12 +248,20 @@ onUnmounted(() => removePageListeners());
</PortfolioSection>
<PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
<template #lead>
<ProjectCaseStudy
v-if="content.projects.selected[0]"
:project="content.projects.selected[0]"
priority="primary"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</template>
<div class="project-stage__projects">
<ProjectCaseStudy
v-for="(project, index) in content.projects.selected"
v-for="(project, index) in content.projects.selected.slice(1)"
:key="project.id"
:project="project"
:priority="index < 2 ? 'primary' : 'supporting'"
:priority="index === 0 ? 'primary' : 'supporting'"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
@@ -281,6 +290,7 @@ onUnmounted(() => removePageListeners());
</main>
</div>
</div>
<span class="content-focus-cue" aria-hidden="true">Portfolio content</span>
<DriveControls
:policy="displayedPolicy"
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"

View File

@@ -47,10 +47,11 @@ const copyAddress = async (): Promise<void> => {
<template>
<img v-if="avatar && avatarUrl" class="contact-avatar" :src="avatarUrl" alt="Illustrated avatar" :width="avatar.width!" :height="avatar.height!" loading="lazy" decoding="async" />
<div class="contact-destination">
<p class="contact-destination__copy">{{ contact.introduction }}</p>
<p class="contact-destination__copy">Thanks for making the trip. The road's open if you'd like to build something together.</p>
<p class="contact-destination__availability">{{ contact.introduction }}</p>
<div class="contact-email" :data-enhanced="enhanced" :data-revealed="revealed" @keydown.esc="closeDisclosure">
<div class="contact-email__action" @pointerenter="revealAddress" @focusin="revealAddress">
<a v-if="enhanced" class="contact-link" :href="address ? `mailto:${address}` : '#contact-email-details'" @pointerdown="revealAddress" @click="revealAddress">Email Me</a>
<a v-if="enhanced" class="contact-link" :href="address ? `mailto:${address}` : '#contact-email-details'" target="_blank" rel="noopener noreferrer" @pointerdown="revealAddress" @click="revealAddress">Email Me</a>
<span v-if="enhanced" class="contact-email__preview" aria-hidden="true">{{ address }}</span>
</div>
<details id="contact-email-details" ref="details" class="contact-email__details" @toggle="handleToggle">

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { PortfolioContent } from '@/content/types';
import NameParticles from './NameParticles.vue';
defineProps<{
withDefaults(defineProps<{
identity: Readonly<PortfolioContent['identity']>;
}>();
allowMotion?: boolean;
}>(), { allowMotion: false });
const emit = defineEmits<{
explore: [event: MouseEvent];
@@ -14,7 +16,7 @@ const emit = defineEmits<{
<template>
<section id="start" class="hero-journey" data-layout="hero" aria-labelledby="start-heading">
<div class="hero-journey__content">
<h1 id="start-heading" tabindex="-1">{{ identity.name }}</h1>
<h1 id="start-heading" class="hero-name" tabindex="-1"><span class="hero-name__text">{{ identity.name }}</span><NameParticles :allow-motion="allowMotion" :name="identity.name" /></h1>
<p class="hero-journey__role">{{ identity.role }}</p>
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
<p class="hero-journey__availability">{{ identity.availability }}</p>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{ allowMotion: boolean; name: string }>();
const root = ref<HTMLElement | null>(null);
const canvas = ref<HTMLCanvasElement | null>(null);
const visible = ref(false);
const pageVisible = ref(false);
const active = computed(() => props.allowMotion && visible.value && pageVisible.value);
let observer: IntersectionObserver | undefined;
let resizeObserver: ResizeObserver | undefined;
let context: CanvasRenderingContext2D | null = null;
let frame: number | undefined;
let width = 0;
let height = 0;
let lastPaint = 0;
let elapsed = 0;
// Option 22 from the standalone gallery. A bounded, low-resolution canvas
// keeps independent particles out of Vue's reactive render path.
let seed = 2054;
const random = (): number => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
const sparks = Array.from({ length: 100 }, () => ({
x: random(), y: random(), duration: random(), phase: random(), size: random(), drift: random(),
}));
const paint = (): void => {
if (!context || width <= 0 || height <= 0) return;
const ctx = context;
ctx.clearRect(0, 0, width, height);
ctx.lineCap = 'round';
const fieldHeight = height - 16;
for (let layer = 0; layer < 2; layer++) {
ctx.globalAlpha = 0.7;
ctx.strokeStyle = layer ? '#ff87c8' : '#91e6ff';
ctx.lineWidth = 1.1;
ctx.beginPath();
for (let x = 0; x <= width; x += 2) {
const y = fieldHeight + 8 + Math.sin(x * 0.027 - elapsed * 2.5 + layer * 2) * 5 * Math.sin(x / width * Math.PI);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
sparks.forEach((spark, index) => {
const phase = (elapsed / (0.65 + spark.duration * 1.5) + spark.phase) % 1;
const life = Math.pow(Math.sin(phase * Math.PI), 1.5);
const x = spark.x * width + (phase - 0.5) * (12 + spark.size * 20);
const y = spark.y * fieldHeight + (phase - 0.5) * (spark.drift - 0.5) * 26;
ctx.beginPath();
if (index % 8 === 0) {
ctx.globalAlpha = life * 0.85;
ctx.strokeStyle = '#75aaff';
ctx.lineWidth = 0.9;
ctx.moveTo(x, y);
ctx.lineTo(x - 5 - spark.duration * 9, y);
ctx.stroke();
} else {
ctx.globalAlpha = life * (0.35 + spark.drift * 0.6);
ctx.fillStyle = '#f5f2ff';
ctx.arc(x, y, 0.5 + spark.size * 1.1, 0, Math.PI * 2);
ctx.fill();
}
});
ctx.globalAlpha = 1;
};
const measure = (): void => {
if (!root.value || !canvas.value || !context) return;
// Layout reads only on resize, never in the animation loop.
const box = root.value.getBoundingClientRect();
width = box.width + 24;
height = box.height + 32;
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.value.width = Math.round(width * ratio);
canvas.value.height = Math.round(height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
if (active.value) paint();
};
const tick = (now: number): void => {
if (!active.value) { frame = undefined; return; }
frame = requestAnimationFrame(tick);
if (now - lastPaint < 1000 / 30) return;
elapsed += Math.min((now - lastPaint) / 1000, 0.1);
lastPaint = now;
paint();
};
const stop = (): void => {
if (frame !== undefined) cancelAnimationFrame(frame);
frame = undefined;
};
watch(active, enabled => {
stop();
if (enabled) {
lastPaint = performance.now();
frame = requestAnimationFrame(tick);
}
}, { flush: 'post' });
const syncVisibility = (): void => { pageVisible.value = !document.hidden; };
onMounted(() => {
context = canvas.value?.getContext('2d') ?? null;
measure();
syncVisibility();
document.addEventListener('visibilitychange', syncVisibility);
if (root.value && context && typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {
visible.value = entries.some(entry => entry.isIntersecting);
});
observer.observe(root.value);
resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(root.value);
}
});
onUnmounted(() => {
stop();
observer?.disconnect();
resizeObserver?.disconnect();
document.removeEventListener('visibilitychange', syncVisibility);
context = null;
});
</script>
<template>
<span ref="root" class="hero-name-particles" aria-hidden="true" :data-active="active" :data-name="name">
<canvas ref="canvas" />
</span>
</template>

View File

@@ -11,8 +11,15 @@ defineProps<{
<template>
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-layout="layout">
<p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>
<div v-if="$slots.lead" class="project-stage__lead">
<p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>
<slot name="lead" />
</div>
<template v-else>
<p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>
</template>
<slot />
</section>
</template>

View File

@@ -54,6 +54,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
:data-scene-generation="sceneGeneration"
:data-static-fallback="!showCanvas"
:data-poster-failed="posterFailed"
:data-arrived="journey.arrived"
>
<picture v-if="!posterFailed">
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { SectionId } from "@/content/types";
import type { JourneySnapshot } from "@/journey/types";
const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>;
journey: JourneySnapshot;
allowMotion?: boolean;
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
}>();
@@ -21,8 +22,22 @@ const navigationLabels: Readonly<Record<SectionId, string>> = {
};
const disclosure = ref<HTMLDetailsElement | null>(null);
const enhanced = ref(false);
const docked = ref(false);
const restScale = ref(1);
let compactQuery: MediaQueryList | null = null;
// Reuse the journey's existing scroll sample; no second scroll/render loop.
watch(() => props.journey.scrollY, y => {
if (y > 24) docked.value = true;
else if (y <= 6) docked.value = false;
}, { immediate: true });
const measureRestWidth = (): void => {
const width = document.documentElement.clientWidth;
if (width > 0) restScale.value = Math.min(1400, Math.max(0, width - 48)) / width;
};
const syncDisclosureToViewport = ({
matches,
}: Pick<MediaQueryList, "matches">): void => {
@@ -31,12 +46,16 @@ const syncDisclosureToViewport = ({
};
onMounted(() => {
measureRestWidth();
enhanced.value = true;
window.addEventListener('resize', measureRestWidth, { passive: true });
compactQuery = window.matchMedia("(max-width: 767px)");
syncDisclosureToViewport(compactQuery);
compactQuery.addEventListener("change", syncDisclosureToViewport);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', measureRestWidth);
compactQuery?.removeEventListener("change", syncDisclosureToViewport);
compactQuery = null;
});
@@ -49,7 +68,7 @@ const navigate = (event: MouseEvent): void => {
</script>
<template>
<nav class="site-navigation" aria-label="Portfolio sections">
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale }">
<details ref="disclosure" class="site-navigation__disclosure" open>
<summary>Navigate</summary>
<ul>

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;

View File

@@ -52,7 +52,10 @@ summary:focus-visible,
}
.portfolio-main:focus-visible {
box-shadow: inset 0 0 0 3px var(--focus);
/* The localized cue lives outside the reading mask. Keep the main landmark
focusable for native skip links without outlining the entire document. */
outline: none;
box-shadow: none;
}
::selection {

View File

@@ -151,7 +151,7 @@
var(--panel-shadow),
inset 0 1px color-mix(in srgb, var(--chapter-accent) 38%, transparent);
}
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
color: color-mix(in srgb, var(--chapter-accent) 30%, var(--text));
}
.portfolio-section .section-eyebrow,
@@ -241,18 +241,20 @@
color: #f6c7dc;
}
.project-case-study:nth-child(3n + 1) {
.project-stage__lead,
.project-stage__projects > .project-case-study:nth-child(3n) {
--chapter-accent: var(--coral);
--chapter-glow: 196 76 125;
}
.project-case-study:nth-child(3n + 2) {
.project-stage__projects > .project-case-study:nth-child(3n + 1) {
--chapter-accent: var(--electric);
--chapter-glow: 64 77 188;
}
.project-case-study:nth-child(3n) {
.project-stage__projects > .project-case-study:nth-child(3n + 2) {
--chapter-accent: var(--violet);
--chapter-glow: 121 59 190;
}
.project-stage__lead,
.project-case-study,
.project-case-study[data-project-priority="supporting"],
.project-case-study[data-project-id][data-media-layout] {

View File

@@ -119,7 +119,7 @@ body {
margin-block: clamp(140px, 23vh, 250px);
border-color: var(--line);
}
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
font-size: clamp(42px, 4.7vw, 72px);
font-weight: 500;
letter-spacing: -0.055em;
@@ -498,7 +498,7 @@ body {
transform: translateY(-4px);
}
@supports (animation-timeline: view()) {
.portfolio-section > h2,
:is(.portfolio-section, .project-stage__lead) > h2,
.project-case-study__header {
animation: grand-tour-enter linear both;
animation-timeline: view();
@@ -627,7 +627,7 @@ body {
padding: 0;
background: transparent;
}
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
font-size: 44px;
margin-block-end: 26px;
}
@@ -764,7 +764,7 @@ body {
}
@media print {
.hero-journey__content > *,
.portfolio-section > h2,
:is(.portfolio-section, .project-stage__lead) > h2,
.project-case-study__header {
animation: none !important;
}
@@ -922,7 +922,7 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
.site-navigation a:hover {
background: rgb(158 220 226 / 8%);
}
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
text-wrap: balance;
}
.project-case-study {
@@ -970,7 +970,7 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
measure these headings. Reveal opacity alone, leaving their geometry stable. */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
animation-name: expressway-heading;
}
}
@@ -1002,7 +1002,7 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
}
@media (max-width: 360px) {
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
font-size: 32px;
}
}
@@ -1024,7 +1024,7 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
padding: 0;
}
.portfolio-section > h2 {
:is(.portfolio-section, .project-stage__lead) > h2 {
font-size: 38px;
}
.experience-record {

View File

@@ -1,3 +1,18 @@
/* The section introduction belongs to the first build's reading surface. */
.project-stage__lead {
padding: clamp(28px, 4vw, 64px);
margin-block-end: clamp(90px, 14vh, 160px);
border: 1px solid color-mix(in srgb, var(--chapter-accent) 30%, transparent);
border-radius: 16px;
}
.project-stage__lead > .project-case-study[data-project-id][data-media-layout] {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
/* Contact follows the same compact reading card as the rest of the journey. */
#contact {
position: relative;
@@ -20,6 +35,13 @@
max-inline-size: 34ch;
text-wrap: pretty;
}
.contact-destination__availability {
margin: 0 0 8px;
font-size: 0.92rem;
line-height: 1.5;
color: var(--text-muted);
text-wrap: pretty;
}
.contact-email {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -173,6 +195,10 @@
}
}
@media (max-width: 767px) {
.project-stage__lead {
padding: 28px 22px;
border-radius: 14px;
}
#contact > h2 {
padding-inline-end: 76px;
}
@@ -205,6 +231,13 @@
}
}
@media print {
.project-stage__lead {
padding: 0;
margin-block-end: 1.25rem;
border: 0;
background: transparent;
box-shadow: none;
}
.contact-avatar {
inset-block-start: 0;
inset-inline-end: 0;

View File

@@ -0,0 +1,145 @@
/* The name remains ordinary, crawlable text; only this small decoration moves. */
.hero-name {
position: relative;
isolation: isolate;
inline-size: min-content;
overflow-wrap: normal;
/* Reserve the waves' full travel plus breathing room before the role. This
stays in place when motion pauses, so the content does not jump. */
margin-block-end: 16px;
}
.hero-name__text {
position: relative;
z-index: 1;
}
.hero-name-particles {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0;
}
.hero-name-particles[data-active='true'] {
opacity: 1;
}
.hero-name-particles canvas {
position: absolute;
inset: -8px auto auto -12px;
inline-size: calc(100% + 24px);
block-size: calc(100% + 32px);
max-inline-size: none;
}
.hero-name-particles::after {
content: attr(data-name);
position: absolute;
inset: 0;
background: linear-gradient(110deg, transparent 30%, #fff8e8 46%, white 49%, transparent 62%);
background-size: 260% 100%;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: name-sheen 3.2s ease-in-out infinite;
animation-play-state: paused;
}
.hero-name-particles[data-active='true']::after {
animation-play-state: running;
}
@keyframes name-sheen {
0%, 12% { background-position: 150% 0; opacity: 0; }
25%, 75% { opacity: 0.95; }
88%, 100% { background-position: -70% 0; opacity: 0; }
}
/* A scaled background expands independently of the links, keeping glyphs sharp
and avoiding per-frame document layout during the docking transition. */
.site-navigation {
border: 0;
background: none;
box-shadow: none;
isolation: isolate;
}
.site-navigation::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
border: 1px solid rgb(198 122 209 / 34%);
border-radius: inherit;
background: linear-gradient(110deg, rgb(39 19 52 / 97%), rgb(22 22 48 / 97%));
box-shadow: 0 8px 32px rgb(13 2 28 / 24%), inset 0 1px rgb(255 159 216 / 7%);
}
.site-navigation a {
border-block-end-color: transparent;
border-radius: 6px;
}
.site-navigation a[aria-current='page'],
.site-navigation a:hover {
border-block-end-color: transparent;
}
@media (min-width: 768px) {
.site-navigation ul {
grid-auto-rows: 44px;
padding: 4px;
gap: 4px;
}
.site-navigation a {
block-size: 44px;
}
.site-navigation[data-enhanced='true'] {
inset: 0 0 auto;
transform: translateY(14px);
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.site-navigation[data-enhanced='true']::before {
transform: scaleX(var(--nav-rest-scale));
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.site-navigation[data-enhanced='true'] .site-navigation__disclosure {
inline-size: min(1400px, calc(100% - 48px));
max-inline-size: none;
}
.site-navigation[data-enhanced='true'][data-docked='true'] {
transform: translateY(0);
}
.site-navigation[data-enhanced='true'][data-docked='true']::before {
transform: scaleX(1);
}
.site-navigation[data-motion='false'],
.site-navigation[data-motion='false']::before {
transition: none;
}
}
/* This cue is a sibling of the masked viewport, not a pseudo-element inside it.
Native main focus and skip-link behavior also work without JavaScript. */
.content-focus-cue {
display: none;
position: fixed;
z-index: var(--z-hud);
inset-block-start: calc(var(--site-navigation-block-size) + 4px);
inset-inline-start: max(24px, calc((100vw - 1400px) / 2));
max-inline-size: calc(100vw - 48px);
padding: 6px 12px;
border: 2px solid var(--focus);
border-radius: 6px;
background: var(--surface-strong);
color: var(--focus);
font: 560 0.8rem/1.5 var(--sans);
pointer-events: none;
}
.reading-stage:has(.portfolio-main:focus-visible) + .content-focus-cue {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.hero-name-particles { display: none; }
.site-navigation, .site-navigation::before { transition: none; }
}
@media (forced-colors: active) {
.hero-name-particles { display: none; }
.site-navigation::before { background: Canvas; border-color: ButtonText; }
.site-navigation a[aria-current='page'] { outline: 1px solid Highlight; outline-offset: -4px; }
.content-focus-cue { background: Canvas; border-color: Highlight; color: CanvasText; }
}
@media print {
.hero-name-particles, .content-focus-cue { display: none !important; }
}

View File

@@ -25,6 +25,11 @@
block-size: 100%;
}
/* A still destination grade when motion is disabled; no animation or WebGL. */
.journey-world[data-static-fallback='true'][data-arrived='true'] .journey-poster {
filter: sepia(0.35) saturate(0.85);
}
.journey-poster {
object-fit: cover;
background:
@@ -528,7 +533,7 @@
/* The stationary reading edge supplies the live reveal. View timelines use
layout positions, which no longer follow the translated content here. */
.reading-stage[data-reading-stage='live'] .portfolio-section > h2,
.reading-stage[data-reading-stage='live'] :is(.portfolio-section, .project-stage__lead) > h2,
.reading-stage[data-reading-stage='live'] .project-case-study__header {
animation: none;
}