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:
@@ -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'"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
132
src/components/NameParticles.vue
Normal file
132
src/components/NameParticles.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user