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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -16,3 +16,8 @@ inputs/original-assets/
|
|||||||
*.map
|
*.map
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/sdd/
|
.superpowers/sdd/
|
||||||
|
|
||||||
|
# Private source material and local visual-check artifacts are never published.
|
||||||
|
/private-handoff/
|
||||||
|
/.playwright-mcp/
|
||||||
|
/baseline-infrastructure.png
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
// These do not need the full suite's fault-injection client.
|
// These do not need the full suite's fault-injection client.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/browser',
|
testDir: './tests/browser',
|
||||||
testMatch: 'contact-refinements.spec.ts',
|
testMatch: ['contact-refinements.spec.ts', 'title-navigation.spec.ts', 'arrival-finale.spec.ts'],
|
||||||
workers: 2,
|
workers: 2,
|
||||||
use: { serviceWorkers: 'block' },
|
use: { serviceWorkers: 'block' },
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ const assertSemanticHtml = (html, inputs, contract) => {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const headings = [...section.querySelectorAll(':scope > h2')]
|
const headings = [...section.querySelectorAll('h2')]
|
||||||
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
.filter((node) => isVisibleSemanticNode(node, appRoot));
|
||||||
if (label === undefined || headings.length !== 1 || headings[0].id !== `${section.id}-heading` ||
|
if (label === undefined || headings.length !== 1 || headings[0].id !== `${section.id}-heading` ||
|
||||||
headings[0].textContent !== label) {
|
headings[0].textContent !== label) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import './styles/world.css';
|
|||||||
import './styles/evolution.css';
|
import './styles/evolution.css';
|
||||||
import './styles/chromatic.css';
|
import './styles/chromatic.css';
|
||||||
import './styles/refinements.css';
|
import './styles/refinements.css';
|
||||||
|
import './styles/title-navigation.css';
|
||||||
import './styles/print.css';
|
import './styles/print.css';
|
||||||
|
|
||||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
<SiteNavigation
|
<SiteNavigation
|
||||||
:items="canonicalNavigation"
|
:items="canonicalNavigation"
|
||||||
:journey="journey"
|
:journey="journey"
|
||||||
|
:allow-motion="motion.policy.value.allowSceneRequest"
|
||||||
:on-section-link-click="navigation.onSectionLinkClick"
|
:on-section-link-click="navigation.onSectionLinkClick"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
@@ -226,7 +227,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
<div class="reading-stage">
|
<div class="reading-stage">
|
||||||
<div class="reading-viewport">
|
<div class="reading-viewport">
|
||||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
<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')">
|
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||||
<p>{{ content.profile.introduction }}</p>
|
<p>{{ content.profile.introduction }}</p>
|
||||||
@@ -247,12 +248,20 @@ onUnmounted(() => removePageListeners());
|
|||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="selected-builds" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
|
<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">
|
<div class="project-stage__projects">
|
||||||
<ProjectCaseStudy
|
<ProjectCaseStudy
|
||||||
v-for="(project, index) in content.projects.selected"
|
v-for="(project, index) in content.projects.selected.slice(1)"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:project="project"
|
:project="project"
|
||||||
:priority="index < 2 ? 'primary' : 'supporting'"
|
:priority="index === 0 ? 'primary' : 'supporting'"
|
||||||
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +290,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="content-focus-cue" aria-hidden="true">Portfolio content</span>
|
||||||
<DriveControls
|
<DriveControls
|
||||||
:policy="displayedPolicy"
|
:policy="displayedPolicy"
|
||||||
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
||||||
|
|||||||
@@ -47,10 +47,11 @@ const copyAddress = async (): Promise<void> => {
|
|||||||
<template>
|
<template>
|
||||||
<img v-if="avatar && avatarUrl" class="contact-avatar" :src="avatarUrl" alt="Illustrated avatar" :width="avatar.width!" :height="avatar.height!" loading="lazy" decoding="async" />
|
<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">
|
<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" :data-enhanced="enhanced" :data-revealed="revealed" @keydown.esc="closeDisclosure">
|
||||||
<div class="contact-email__action" @pointerenter="revealAddress" @focusin="revealAddress">
|
<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>
|
<span v-if="enhanced" class="contact-email__preview" aria-hidden="true">{{ address }}</span>
|
||||||
</div>
|
</div>
|
||||||
<details id="contact-email-details" ref="details" class="contact-email__details" @toggle="handleToggle">
|
<details id="contact-email-details" ref="details" class="contact-email__details" @toggle="handleToggle">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PortfolioContent } from '@/content/types';
|
import type { PortfolioContent } from '@/content/types';
|
||||||
|
import NameParticles from './NameParticles.vue';
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(defineProps<{
|
||||||
identity: Readonly<PortfolioContent['identity']>;
|
identity: Readonly<PortfolioContent['identity']>;
|
||||||
}>();
|
allowMotion?: boolean;
|
||||||
|
}>(), { allowMotion: false });
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
explore: [event: MouseEvent];
|
explore: [event: MouseEvent];
|
||||||
@@ -14,7 +16,7 @@ const emit = defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<section id="start" class="hero-journey" data-layout="hero" aria-labelledby="start-heading">
|
<section id="start" class="hero-journey" data-layout="hero" aria-labelledby="start-heading">
|
||||||
<div class="hero-journey__content">
|
<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__role">{{ identity.role }}</p>
|
||||||
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
||||||
<p class="hero-journey__availability">{{ identity.availability }}</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>
|
<template>
|
||||||
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-layout="layout">
|
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-layout="layout">
|
||||||
<p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
|
<div v-if="$slots.lead" class="project-stage__lead">
|
||||||
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>
|
<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 />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
|
|||||||
:data-scene-generation="sceneGeneration"
|
:data-scene-generation="sceneGeneration"
|
||||||
:data-static-fallback="!showCanvas"
|
:data-static-fallback="!showCanvas"
|
||||||
:data-poster-failed="posterFailed"
|
:data-poster-failed="posterFailed"
|
||||||
|
:data-arrived="journey.arrived"
|
||||||
>
|
>
|
||||||
<picture v-if="!posterFailed">
|
<picture v-if="!posterFailed">
|
||||||
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
|
<source media="(max-width: 767px)" :srcset="mobilePosterUrl">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<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 { SectionId } from "@/content/types";
|
||||||
import type { JourneySnapshot } from "@/journey/types";
|
import type { JourneySnapshot } from "@/journey/types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||||
journey: JourneySnapshot;
|
journey: JourneySnapshot;
|
||||||
|
allowMotion?: boolean;
|
||||||
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -21,8 +22,22 @@ const navigationLabels: Readonly<Record<SectionId, string>> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const disclosure = ref<HTMLDetailsElement | null>(null);
|
const disclosure = ref<HTMLDetailsElement | null>(null);
|
||||||
|
const enhanced = ref(false);
|
||||||
|
const docked = ref(false);
|
||||||
|
const restScale = ref(1);
|
||||||
let compactQuery: MediaQueryList | null = null;
|
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 = ({
|
const syncDisclosureToViewport = ({
|
||||||
matches,
|
matches,
|
||||||
}: Pick<MediaQueryList, "matches">): void => {
|
}: Pick<MediaQueryList, "matches">): void => {
|
||||||
@@ -31,12 +46,16 @@ const syncDisclosureToViewport = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
measureRestWidth();
|
||||||
|
enhanced.value = true;
|
||||||
|
window.addEventListener('resize', measureRestWidth, { passive: true });
|
||||||
compactQuery = window.matchMedia("(max-width: 767px)");
|
compactQuery = window.matchMedia("(max-width: 767px)");
|
||||||
syncDisclosureToViewport(compactQuery);
|
syncDisclosureToViewport(compactQuery);
|
||||||
compactQuery.addEventListener("change", syncDisclosureToViewport);
|
compactQuery.addEventListener("change", syncDisclosureToViewport);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', measureRestWidth);
|
||||||
compactQuery?.removeEventListener("change", syncDisclosureToViewport);
|
compactQuery?.removeEventListener("change", syncDisclosureToViewport);
|
||||||
compactQuery = null;
|
compactQuery = null;
|
||||||
});
|
});
|
||||||
@@ -49,7 +68,7 @@ const navigate = (event: MouseEvent): void => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<details ref="disclosure" class="site-navigation__disclosure" open>
|
||||||
<summary>Navigate</summary>
|
<summary>Navigate</summary>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
91
src/scene/world/arrival-meteors.ts
Normal file
91
src/scene/world/arrival-meteors.ts
Normal 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;
|
||||||
|
} };
|
||||||
|
}
|
||||||
@@ -315,6 +315,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
snapshot.routeProgress,
|
snapshot.routeProgress,
|
||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
response.bloomEnergy,
|
response.bloomEnergy,
|
||||||
|
snapshot.arrived,
|
||||||
);
|
);
|
||||||
car.update(
|
car.update(
|
||||||
visualSpeed,
|
visualSpeed,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function createSignStage(resources: SceneResourceBag) {
|
|||||||
let imageObserver: IntersectionObserver | undefined;
|
let imageObserver: IntersectionObserver | undefined;
|
||||||
const headings = main
|
const headings = main
|
||||||
? Array.from(main.children).flatMap((panel) => {
|
? Array.from(main.children).flatMap((panel) => {
|
||||||
const heading = panel.querySelector<HTMLElement>(':scope > h2');
|
const heading = panel.querySelector<HTMLElement>('h2');
|
||||||
return panel instanceof HTMLElement && heading
|
return panel instanceof HTMLElement && heading
|
||||||
? [{ panel, heading }]
|
? [{ panel, heading }]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SceneResourceBag } from '@/scene/resources';
|
|||||||
import type { MutableChapterPalette } from '@/scene/world/palette';
|
import type { MutableChapterPalette } from '@/scene/world/palette';
|
||||||
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
import { CHAPTER_PALETTES } from '@/scene/world/palette';
|
||||||
import { SUN_DISTANCE_Z } from '@/scene/world/metrics';
|
import { SUN_DISTANCE_Z } from '@/scene/world/metrics';
|
||||||
|
import { createArrivalMeteors } from './arrival-meteors';
|
||||||
|
|
||||||
export interface SunWorld {
|
export interface SunWorld {
|
||||||
group: WorldGroup;
|
group: WorldGroup;
|
||||||
@@ -11,6 +12,7 @@ export interface SunWorld {
|
|||||||
routeProgress: number,
|
routeProgress: number,
|
||||||
elapsedSeconds: number,
|
elapsedSeconds: number,
|
||||||
driveEnergy?: number,
|
driveEnergy?: number,
|
||||||
|
arrived?: boolean,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +26,9 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
group.name = 'sun-world';
|
group.name = 'sun-world';
|
||||||
const night = CHAPTER_PALETTES['deep-night'];
|
const night = CHAPTER_PALETTES['deep-night'];
|
||||||
const sunColor = new THREE.Color(night.sun);
|
const sunColor = new THREE.Color(night.sun);
|
||||||
|
const arrivalColor = new THREE.Color(0xffead0);
|
||||||
|
let wasArrived = false;
|
||||||
|
let arrivalStartedAt = 0;
|
||||||
const uniforms = {
|
const uniforms = {
|
||||||
uColor: { value: sunColor },
|
uColor: { value: sunColor },
|
||||||
uHazeColor: { value: new THREE.Color(night.haze) },
|
uHazeColor: { value: new THREE.Color(night.haze) },
|
||||||
@@ -32,6 +37,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
uRouteProgress: { value: 0 },
|
uRouteProgress: { value: 0 },
|
||||||
uTime: { value: 0 },
|
uTime: { value: 0 },
|
||||||
uDriveEnergy: { value: 0 },
|
uDriveEnergy: { value: 0 },
|
||||||
|
uArrival: { value: 0 },
|
||||||
};
|
};
|
||||||
const geometry = resources.own(
|
const geometry = resources.own(
|
||||||
'geometry',
|
'geometry',
|
||||||
@@ -57,6 +63,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
uniform float uRouteProgress;
|
uniform float uRouteProgress;
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform float uDriveEnergy;
|
uniform float uDriveEnergy;
|
||||||
|
uniform float uArrival;
|
||||||
varying vec2 vSunUv;
|
varying vec2 vSunUv;
|
||||||
|
|
||||||
void main() {
|
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 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))
|
float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
|
||||||
* sin(solar.y * 31.0 - uTime * 0.032);
|
* 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 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));
|
vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
|
||||||
core = mix(core, crown, smoothstep(0.47, 1.0, 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);
|
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: {
|
uniforms: {
|
||||||
uColor: { value: sunColor.clone() },
|
uColor: { value: sunColor.clone() },
|
||||||
uEnergy: { value: 0 },
|
uEnergy: { value: 0 },
|
||||||
|
uScale: { value: 1 },
|
||||||
|
uRingRadius: { value: 0.49 },
|
||||||
|
uRingOpacity: { value: 0 },
|
||||||
|
uArrival: { value: 0 },
|
||||||
},
|
},
|
||||||
vertexShader: `
|
vertexShader: `
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
@@ -118,12 +129,18 @@ export function createSun(resources: SceneResourceBag): SunWorld {
|
|||||||
fragmentShader: `
|
fragmentShader: `
|
||||||
uniform vec3 uColor;
|
uniform vec3 uColor;
|
||||||
uniform float uEnergy;
|
uniform float uEnergy;
|
||||||
|
uniform float uScale;
|
||||||
|
uniform float uRingRadius;
|
||||||
|
uniform float uRingOpacity;
|
||||||
|
uniform float uArrival;
|
||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
void main() {
|
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)
|
float ring = smoothstep(0.477, 0.488, radius)
|
||||||
* (1.0 - smoothstep(0.491, 0.499, 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 <tonemapping_fragment>
|
||||||
#include <colorspace_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.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
|
||||||
bleed.scale.set(560, 118, 1);
|
bleed.scale.set(560, 118, 1);
|
||||||
group.add(bleed);
|
group.add(bleed);
|
||||||
|
const meteors = createArrivalMeteors(resources);
|
||||||
|
group.add(meteors.mesh);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0) {
|
update(palette, routeProgress, elapsedSeconds, driveEnergy = 0, arrived = false) {
|
||||||
const progress = clamp01(routeProgress);
|
const progress = clamp01(routeProgress);
|
||||||
const energy = clamp01(driveEnergy);
|
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);
|
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);
|
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.uWarmth.value = clamp01(palette.warmth);
|
||||||
uniforms.uRouteProgress.value = progress;
|
uniforms.uRouteProgress.value = progress;
|
||||||
uniforms.uTime.value = Number.isFinite(elapsedSeconds)
|
uniforms.uTime.value = time;
|
||||||
? elapsedSeconds
|
|
||||||
: 0;
|
|
||||||
uniforms.uDriveEnergy.value = energy;
|
uniforms.uDriveEnergy.value = energy;
|
||||||
|
uniforms.uArrival.value = arrivalGlow;
|
||||||
limbMaterial.uniforms.uEnergy.value = energy;
|
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;
|
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;
|
disc.position.y = 79 + palette.warmth * 14;
|
||||||
limb.position.y = disc.position.y;
|
limb.position.y = disc.position.y;
|
||||||
halo.position.y = disc.position.y;
|
halo.position.y = disc.position.y;
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ summary:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.portfolio-main: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 {
|
::selection {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
var(--panel-shadow),
|
var(--panel-shadow),
|
||||||
inset 0 1px color-mix(in srgb, var(--chapter-accent) 38%, transparent);
|
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));
|
color: color-mix(in srgb, var(--chapter-accent) 30%, var(--text));
|
||||||
}
|
}
|
||||||
.portfolio-section .section-eyebrow,
|
.portfolio-section .section-eyebrow,
|
||||||
@@ -241,18 +241,20 @@
|
|||||||
color: #f6c7dc;
|
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-accent: var(--coral);
|
||||||
--chapter-glow: 196 76 125;
|
--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-accent: var(--electric);
|
||||||
--chapter-glow: 64 77 188;
|
--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-accent: var(--violet);
|
||||||
--chapter-glow: 121 59 190;
|
--chapter-glow: 121 59 190;
|
||||||
}
|
}
|
||||||
|
.project-stage__lead,
|
||||||
.project-case-study,
|
.project-case-study,
|
||||||
.project-case-study[data-project-priority="supporting"],
|
.project-case-study[data-project-priority="supporting"],
|
||||||
.project-case-study[data-project-id][data-media-layout] {
|
.project-case-study[data-project-id][data-media-layout] {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ body {
|
|||||||
margin-block: clamp(140px, 23vh, 250px);
|
margin-block: clamp(140px, 23vh, 250px);
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
}
|
}
|
||||||
.portfolio-section > h2 {
|
:is(.portfolio-section, .project-stage__lead) > h2 {
|
||||||
font-size: clamp(42px, 4.7vw, 72px);
|
font-size: clamp(42px, 4.7vw, 72px);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.055em;
|
letter-spacing: -0.055em;
|
||||||
@@ -498,7 +498,7 @@ body {
|
|||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
@supports (animation-timeline: view()) {
|
@supports (animation-timeline: view()) {
|
||||||
.portfolio-section > h2,
|
:is(.portfolio-section, .project-stage__lead) > h2,
|
||||||
.project-case-study__header {
|
.project-case-study__header {
|
||||||
animation: grand-tour-enter linear both;
|
animation: grand-tour-enter linear both;
|
||||||
animation-timeline: view();
|
animation-timeline: view();
|
||||||
@@ -627,7 +627,7 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.portfolio-section > h2 {
|
:is(.portfolio-section, .project-stage__lead) > h2 {
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
margin-block-end: 26px;
|
margin-block-end: 26px;
|
||||||
}
|
}
|
||||||
@@ -764,7 +764,7 @@ body {
|
|||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
.hero-journey__content > *,
|
.hero-journey__content > *,
|
||||||
.portfolio-section > h2,
|
:is(.portfolio-section, .project-stage__lead) > h2,
|
||||||
.project-case-study__header {
|
.project-case-study__header {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
@@ -922,7 +922,7 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
|
|||||||
.site-navigation a:hover {
|
.site-navigation a:hover {
|
||||||
background: rgb(158 220 226 / 8%);
|
background: rgb(158 220 226 / 8%);
|
||||||
}
|
}
|
||||||
.portfolio-section > h2 {
|
:is(.portfolio-section, .project-stage__lead) > h2 {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.project-case-study {
|
.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. */
|
measure these headings. Reveal opacity alone, leaving their geometry stable. */
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
@supports (animation-timeline: view()) {
|
@supports (animation-timeline: view()) {
|
||||||
.portfolio-section > h2 {
|
:is(.portfolio-section, .project-stage__lead) > h2 {
|
||||||
animation-name: expressway-heading;
|
animation-name: expressway-heading;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1002,7 @@ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.portfolio-section > h2 {
|
:is(.portfolio-section, .project-stage__lead) > h2 {
|
||||||
font-size: 32px;
|
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'] {
|
html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.portfolio-section > h2 {
|
:is(.portfolio-section, .project-stage__lead) > h2 {
|
||||||
font-size: 38px;
|
font-size: 38px;
|
||||||
}
|
}
|
||||||
.experience-record {
|
.experience-record {
|
||||||
|
|||||||
@@ -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 follows the same compact reading card as the rest of the journey. */
|
||||||
#contact {
|
#contact {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -20,6 +35,13 @@
|
|||||||
max-inline-size: 34ch;
|
max-inline-size: 34ch;
|
||||||
text-wrap: pretty;
|
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 {
|
.contact-email {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -173,6 +195,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.project-stage__lead {
|
||||||
|
padding: 28px 22px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
#contact > h2 {
|
#contact > h2 {
|
||||||
padding-inline-end: 76px;
|
padding-inline-end: 76px;
|
||||||
}
|
}
|
||||||
@@ -205,6 +231,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
|
.project-stage__lead {
|
||||||
|
padding: 0;
|
||||||
|
margin-block-end: 1.25rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
.contact-avatar {
|
.contact-avatar {
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
|
|||||||
145
src/styles/title-navigation.css
Normal file
145
src/styles/title-navigation.css
Normal 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; }
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
block-size: 100%;
|
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 {
|
.journey-poster {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background:
|
background:
|
||||||
@@ -528,7 +533,7 @@
|
|||||||
|
|
||||||
/* The stationary reading edge supplies the live reveal. View timelines use
|
/* The stationary reading edge supplies the live reveal. View timelines use
|
||||||
layout positions, which no longer follow the translated content here. */
|
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 {
|
.reading-stage[data-reading-stage='live'] .project-case-study__header {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
76
tests/browser/arrival-finale.spec.ts
Normal file
76
tests/browser/arrival-finale.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
test('arrival draws six meteor triangles, uses warmed shaders, then stops drawing the meteors', async ({ page, distUrl }, testInfo) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
if (testInfo.project.name === 'desktop-chromium') await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.addInitScript((quality: string) => {
|
||||||
|
localStorage.setItem('portfolio.motion.maximum-quality', quality);
|
||||||
|
const probe = { draws: 0, maxVertices: 0, programs: 0, frames: 0, time: -1, ring: 0 };
|
||||||
|
(window as any).__arrivalProbe = probe;
|
||||||
|
const proto = WebGL2RenderingContext.prototype;
|
||||||
|
const meteorPrograms = new WeakSet<WebGLProgram>();
|
||||||
|
const locations = new WeakMap<WebGLUniformLocation, { program: WebGLProgram; name: string }>();
|
||||||
|
let activeProgram: WebGLProgram | null = null;
|
||||||
|
const meteorShaders = new WeakSet<WebGLShader>();
|
||||||
|
const source = proto.shaderSource;
|
||||||
|
proto.shaderSource = function (shader, text) {
|
||||||
|
if (text.includes('attribute vec2 aFlight;')) meteorShaders.add(shader);
|
||||||
|
return source.call(this, shader, text);
|
||||||
|
};
|
||||||
|
const attach = proto.attachShader;
|
||||||
|
proto.attachShader = function (program, shader) {
|
||||||
|
if (meteorShaders.has(shader) && !meteorPrograms.has(program)) {
|
||||||
|
meteorPrograms.add(program);
|
||||||
|
probe.programs++;
|
||||||
|
}
|
||||||
|
return attach.call(this, program, shader);
|
||||||
|
};
|
||||||
|
const getUniform = proto.getUniformLocation;
|
||||||
|
proto.getUniformLocation = function (program, name) {
|
||||||
|
const location = getUniform.call(this, program, name);
|
||||||
|
if (location) locations.set(location, { program, name });
|
||||||
|
return location;
|
||||||
|
};
|
||||||
|
const uniform = proto.uniform1f;
|
||||||
|
proto.uniform1f = function (location, value) {
|
||||||
|
const entry = location && locations.get(location);
|
||||||
|
if (entry?.name === 'uTime' && meteorPrograms.has(entry.program)) probe.time = value;
|
||||||
|
if (entry?.name === 'uRingOpacity') probe.ring = value;
|
||||||
|
return uniform.call(this, location, value);
|
||||||
|
};
|
||||||
|
const useProgram = proto.useProgram;
|
||||||
|
proto.useProgram = function (program) { activeProgram = program; return useProgram.call(this, program); };
|
||||||
|
const draw = proto.drawArrays;
|
||||||
|
proto.drawArrays = function (mode, first, count) {
|
||||||
|
if (activeProgram && meteorPrograms.has(activeProgram)) {
|
||||||
|
probe.draws++;
|
||||||
|
probe.maxVertices = Math.max(probe.maxVertices, count);
|
||||||
|
}
|
||||||
|
return draw.call(this, mode, first, count);
|
||||||
|
};
|
||||||
|
const clear = proto.clear;
|
||||||
|
proto.clear = function (mask) { probe.frames++; return clear.call(this, mask); };
|
||||||
|
}, process.env.ARRIVAL_TEST_QUALITY === 'high' ? 'high' : 'low');
|
||||||
|
await page.goto(distUrl);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-expressway', 'live');
|
||||||
|
const read = () => page.evaluate(() => ({ ...(window as any).__arrivalProbe }));
|
||||||
|
const baseline = await read();
|
||||||
|
expect(baseline.draws).toBe(1); // Transparent startup warm-up only.
|
||||||
|
expect(baseline.programs).toBe(1);
|
||||||
|
await page.locator('.hero-journey__action--primary').click();
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-arrived', 'true');
|
||||||
|
await expect.poll(async () => (await read()).draws, { timeout: 12_000 }).toBeGreaterThan(baseline.draws);
|
||||||
|
await expect.poll(async () => (await read()).ring).toBeGreaterThan(0);
|
||||||
|
await expect.poll(async () => (await read()).time).toBeGreaterThan(1.2);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('arrival-active.png') });
|
||||||
|
await expect.poll(async () => (await read()).time, { timeout: 15_000 }).toBeGreaterThan(3.8);
|
||||||
|
const ending = await read();
|
||||||
|
await expect.poll(async () => (await read()).frames).toBeGreaterThan(ending.frames + 30);
|
||||||
|
const settled = await read();
|
||||||
|
expect(settled.maxVertices).toBe(18);
|
||||||
|
expect(settled.programs).toBe(baseline.programs);
|
||||||
|
await expect.poll(async () => (await read()).ring).toBeGreaterThan(0);
|
||||||
|
await expect.poll(async () => (await read()).frames).toBeGreaterThan(settled.frames + 30);
|
||||||
|
expect((await read()).draws).toBe(settled.draws);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('arrival-settled.png') });
|
||||||
|
});
|
||||||
@@ -1,6 +1,40 @@
|
|||||||
import AxeBuilder from '@axe-core/playwright';
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
import { expect, test } from '../helpers/serve-dist';
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
test('builds heading shares the first project surface and contact keeps a clear copy hierarchy', async ({ page, distUrl }) => {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.goto(distUrl);
|
||||||
|
const joined = await page.locator('#selected-builds-heading').evaluate(heading => {
|
||||||
|
const surface = heading.closest('.project-stage__lead');
|
||||||
|
return Boolean(surface?.querySelector('.project-case-study') &&
|
||||||
|
getComputedStyle(surface).backgroundColor !== 'rgba(0, 0, 0, 0)');
|
||||||
|
});
|
||||||
|
expect(joined).toBe(true);
|
||||||
|
const hierarchy = await page.locator('#contact').evaluate(section => {
|
||||||
|
const lead = section.querySelector('.contact-destination__copy');
|
||||||
|
const availability = section.querySelector('.contact-destination__availability');
|
||||||
|
return Boolean(lead && availability &&
|
||||||
|
parseFloat(getComputedStyle(lead).fontSize) > parseFloat(getComputedStyle(availability).fontSize));
|
||||||
|
});
|
||||||
|
expect(hierarchy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the live reading mask measures the inset builds heading', async ({ page, distUrl }) => {
|
||||||
|
await page.goto(distUrl);
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-expressway', 'live');
|
||||||
|
const geometry = await page.locator('#selected-builds').evaluate(section => ({
|
||||||
|
offset: parseFloat((section as HTMLElement).style.getPropertyValue('--chapter-heading-offset')) || 0,
|
||||||
|
actual: section.querySelector('h2')!.getBoundingClientRect().top - section.getBoundingClientRect().top,
|
||||||
|
}));
|
||||||
|
expect(geometry.offset).toBeGreaterThan(20);
|
||||||
|
expect(geometry.offset).toBeCloseTo(geometry.actual, 1);
|
||||||
|
const disclosure = page.locator('.site-navigation summary');
|
||||||
|
if (await disclosure.isVisible()) await disclosure.click();
|
||||||
|
await page.locator('.site-navigation a[href="#selected-builds"]').click();
|
||||||
|
await expect.poll(() => page.locator('#selected-builds-heading').evaluate(heading =>
|
||||||
|
Number(getComputedStyle(heading).opacity))).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('start navigation, contact identity, and toolkit icons use the approved content', async ({ page, distUrl }) => {
|
test('start navigation, contact identity, and toolkit icons use the approved content', async ({ page, distUrl }) => {
|
||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
const response = await page.goto(distUrl);
|
const response = await page.goto(distUrl);
|
||||||
@@ -11,6 +45,10 @@ test('start navigation, contact identity, and toolkit icons use the approved con
|
|||||||
await page.locator('.hero-journey__action--primary').click();
|
await page.locator('.hero-journey__action--primary').click();
|
||||||
await expect(page).toHaveURL(/#contact$/);
|
await expect(page).toHaveURL(/#contact$/);
|
||||||
await expect(page.locator('#contact')).toBeInViewport();
|
await expect(page.locator('#contact')).toBeInViewport();
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-arrived', 'true');
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
|
||||||
|
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||||
|
expect(await page.locator('.journey-poster').evaluate(poster => getComputedStyle(poster).filter)).not.toBe('none');
|
||||||
await expect(page.locator('.contact-avatar')).toBeVisible();
|
await expect(page.locator('.contact-avatar')).toBeVisible();
|
||||||
await expect(page.locator('.contact-social-link')).toHaveText(['LinkedIn', 'GitHub', 'GitLab']);
|
await expect(page.locator('.contact-social-link')).toHaveText(['LinkedIn', 'GitHub', 'GitLab']);
|
||||||
const missing = await page.locator('[data-testid="toolkit-technology"]').evaluateAll(items => items.filter(item => !item.querySelector('svg')).map(item => item.textContent));
|
const missing = await page.locator('[data-testid="toolkit-technology"]').evaluateAll(items => items.filter(item => !item.querySelector('svg')).map(item => item.textContent));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
import { expect, expectContentFocusCue, test } from "../helpers/serve-dist";
|
||||||
|
|
||||||
const navHrefs = [
|
const navHrefs = [
|
||||||
"#start",
|
"#start",
|
||||||
@@ -130,7 +130,7 @@ test("activating the skip link focuses the visible unobscured portfolio content"
|
|||||||
"tabindex",
|
"tabindex",
|
||||||
"-1",
|
"-1",
|
||||||
);
|
);
|
||||||
await expectInsetFocusIndicator(page, "#portfolio-content");
|
await expectContentFocusCue(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("explicit section activation scrolls to and focuses its heading", async ({
|
test("explicit section activation scrolls to and focuses its heading", async ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PortfolioContent } from "@/content/types";
|
import type { PortfolioContent } from "@/content/types";
|
||||||
import { expect, expectInsetFocusIndicator, test } from "../helpers/serve-dist";
|
import { expect, expectContentFocusCue, test } from "../helpers/serve-dist";
|
||||||
|
|
||||||
test.use({ javaScriptEnabled: false });
|
test.use({ javaScriptEnabled: false });
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ test("keeps every raw SSR reading-path segment visible without JavaScript", asyn
|
|||||||
.locator("main > section")
|
.locator("main > section")
|
||||||
.evaluateAll((sections) => sections.map((section) => section.id)),
|
.evaluateAll((sections) => sections.map((section) => section.id)),
|
||||||
).toEqual(sectionIds);
|
).toEqual(sectionIds);
|
||||||
expect(await page.locator("main > section > h2").allTextContents()).toEqual(
|
expect(await page.locator("main > section h2").allTextContents()).toEqual(
|
||||||
sectionHeadings,
|
sectionHeadings,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -518,7 +518,7 @@ test("activating the skip link focuses visible portfolio content without JavaScr
|
|||||||
"tabindex",
|
"tabindex",
|
||||||
"-1",
|
"-1",
|
||||||
);
|
);
|
||||||
await expectInsetFocusIndicator(page, "#portfolio-content");
|
await expectContentFocusCue(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("retains native section navigation when scripts are disabled", async ({
|
test("retains native section navigation when scripts are disabled", async ({
|
||||||
|
|||||||
143
tests/browser/title-navigation.spec.ts
Normal file
143
tests/browser/title-navigation.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
test('desktop navigation docks without stretching labels or shifting the document', async ({ page, distUrl }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium');
|
||||||
|
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'networkidle' });
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
const nav = page.locator('.site-navigation');
|
||||||
|
await expect(nav).toHaveAttribute('data-enhanced', 'true');
|
||||||
|
const surfaceScale = () => nav.evaluate(el => new DOMMatrixReadOnly(getComputedStyle(el, '::before').transform).a);
|
||||||
|
expect(await surfaceScale()).toBeLessThan(1);
|
||||||
|
const label = nav.locator('a').first();
|
||||||
|
const initialFont = await label.evaluate(el => getComputedStyle(el).fontSize);
|
||||||
|
// Measure the content beside this fixed header, not unrelated lazy media
|
||||||
|
// farther down the page that can settle independently of navigation.
|
||||||
|
const heroGeometry = () => page.locator('#start').evaluate(el => ({
|
||||||
|
top: el.getBoundingClientRect().top + scrollY,
|
||||||
|
height: el.getBoundingClientRect().height,
|
||||||
|
}));
|
||||||
|
const initialHero = await heroGeometry();
|
||||||
|
await page.evaluate(() => scrollTo(0, 100));
|
||||||
|
await expect.poll(async () => (await nav.boundingBox())!.y).toBeLessThan(1);
|
||||||
|
await expect.poll(async () => (await nav.boundingBox())!.width).toBe(1920);
|
||||||
|
await expect.poll(surfaceScale).toBe(1);
|
||||||
|
expect(await label.evaluate(el => getComputedStyle(el).fontSize)).toBe(initialFont);
|
||||||
|
expect(await heroGeometry()).toEqual(initialHero);
|
||||||
|
await page.evaluate(() => scrollTo(0, 0));
|
||||||
|
await expect.poll(async () => (await nav.boundingBox())!.y).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selected navigation has rounded inset highlights', async ({ page, distUrl }) => {
|
||||||
|
await page.goto(distUrl);
|
||||||
|
const disclosure = page.locator('.site-navigation summary');
|
||||||
|
if (await disclosure.isVisible()) await disclosure.click();
|
||||||
|
const selected = page.locator('.site-navigation a[aria-current="page"]');
|
||||||
|
expect(await selected.evaluate(el => parseFloat(getComputedStyle(el).borderTopLeftRadius))).toBeGreaterThanOrEqual(5);
|
||||||
|
await selected.focus();
|
||||||
|
await page.keyboard.press('Shift');
|
||||||
|
expect(await selected.evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skip link uses a localized visible cue and Tab continues into content', async ({ page, distUrl }) => {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.goto(distUrl);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(page.locator('.skip-link')).toBeFocused();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const main = page.locator('#portfolio-content');
|
||||||
|
await expect(main).toBeFocused();
|
||||||
|
expect(await main.evaluate(el => getComputedStyle(el).boxShadow)).toBe('none');
|
||||||
|
expect(await main.evaluate(el => getComputedStyle(el).outlineStyle)).toBe('none');
|
||||||
|
await expect(page.locator('.content-focus-cue')).toBeVisible();
|
||||||
|
const cue = await page.locator('.content-focus-cue').boundingBox();
|
||||||
|
expect(cue!.width).toBeLessThan(240);
|
||||||
|
expect(cue!.height).toBeLessThan(60);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(page.locator('.hero-journey__action--primary')).toBeFocused();
|
||||||
|
expect(await page.locator('.hero-journey__action--primary').evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||||
|
await expect(page.locator('.content-focus-cue')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Shift cannot draw a page-wide focus frame in the live reading stage', async ({ page, distUrl }) => {
|
||||||
|
await page.goto(distUrl);
|
||||||
|
await page.locator('main').evaluate(el => (el as HTMLElement).focus({ preventScroll: true }));
|
||||||
|
await page.keyboard.press('Shift');
|
||||||
|
expect(await page.locator('main').evaluate(el => ({ outline: getComputedStyle(el).outlineStyle, shadow: getComputedStyle(el).boxShadow }))).toEqual({ outline: 'none', shadow: 'none' });
|
||||||
|
await page.locator('.hero-journey__action--primary').focus();
|
||||||
|
await page.keyboard.press('Shift');
|
||||||
|
expect(await page.locator('.hero-journey__action--primary').evaluate(el => getComputedStyle(el).outlineStyle)).not.toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name decoration pauses offscreen and leaves one readable heading', async ({ page, distUrl }) => {
|
||||||
|
await page.goto(distUrl);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Luca Pîslaru');
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
const title = await page.locator('.hero-name').evaluate(el => ({
|
||||||
|
height: el.getBoundingClientRect().height,
|
||||||
|
lineHeight: parseFloat(getComputedStyle(el).lineHeight),
|
||||||
|
}));
|
||||||
|
expect(title.height).toBeLessThanOrEqual(title.lineHeight * 2.1);
|
||||||
|
const particles = page.locator('.hero-name-particles');
|
||||||
|
await expect(particles).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
await expect(particles).toHaveAttribute('data-active', 'true');
|
||||||
|
const canvas = particles.locator('canvas');
|
||||||
|
await expect(canvas).toHaveCount(1);
|
||||||
|
const nameBox = await particles.boundingBox();
|
||||||
|
const effectBox = await canvas.boundingBox();
|
||||||
|
expect(effectBox!.x).toBeCloseTo(nameBox!.x - 12, 0);
|
||||||
|
expect(effectBox!.width).toBeCloseTo(nameBox!.width + 24, 0);
|
||||||
|
const roleBox = await page.locator('.hero-journey__role').boundingBox();
|
||||||
|
// The complete drawing area (including every wave crest) clears the role.
|
||||||
|
expect(roleBox!.y - (effectBox!.y + effectBox!.height)).toBeGreaterThanOrEqual(4);
|
||||||
|
await expect.poll(() => canvas.evaluate(el => {
|
||||||
|
const context = (el as HTMLCanvasElement).getContext('2d')!;
|
||||||
|
const { width, height } = el as HTMLCanvasElement;
|
||||||
|
// Sparks occupy the name area, while the waves sit below it.
|
||||||
|
return [
|
||||||
|
context.getImageData(0, 0, width, Math.floor(height * 0.75)).data.some(value => value > 0),
|
||||||
|
context.getImageData(0, Math.floor(height * 0.92), width, Math.max(1, Math.floor(height * 0.08))).data.some(value => value > 0),
|
||||||
|
];
|
||||||
|
})).toEqual([true, true]);
|
||||||
|
expect(await particles.evaluate(el => getComputedStyle(el, '::after').animationName)).toBe('name-sheen');
|
||||||
|
expect(await particles.evaluate(el => getComputedStyle(el, '::after').animationPlayState)).toBe('running');
|
||||||
|
const firstFrame = await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL());
|
||||||
|
await expect.poll(() => canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL())).not.toBe(firstFrame);
|
||||||
|
await page.locator('#contact').scrollIntoViewIfNeeded();
|
||||||
|
await expect(particles).toHaveAttribute('data-active', 'false');
|
||||||
|
expect(await particles.evaluate(el => getComputedStyle(el, '::after').animationPlayState)).toBe('paused');
|
||||||
|
const stoppedFrame = await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL());
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
expect(await canvas.evaluate(el => (el as HTMLCanvasElement).toDataURL())).toBe(stoppedFrame);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wave animation clears the role text across desktop, short screens, and narrow phones', async ({ page, distUrl }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium');
|
||||||
|
await page.goto(distUrl);
|
||||||
|
for (const [width, height] of [[1920, 1080], [1024, 768], [820, 600], [768, 520], [393, 851], [320, 640]]) {
|
||||||
|
await page.setViewportSize({ width: width!, height: height! });
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const effect = await page.locator('.hero-name-particles canvas').boundingBox();
|
||||||
|
const role = await page.locator('.hero-journey__role').boundingBox();
|
||||||
|
return role!.y - (effect!.y + effect!.height);
|
||||||
|
}, { message: `Wave clearance at ${width} × ${height}` }).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reduced motion and no-JavaScript retain the name and native skip link', async ({ browser, page, distUrl }) => {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await page.goto(distUrl);
|
||||||
|
await expect(page.locator('.hero-name-particles')).toHaveAttribute('data-active', 'false');
|
||||||
|
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||||
|
const native = await context.newPage();
|
||||||
|
await native.goto(distUrl);
|
||||||
|
await expect(native.getByRole('heading', { level: 1 })).toHaveText('Luca Pîslaru');
|
||||||
|
await native.keyboard.press('Tab');
|
||||||
|
await native.keyboard.press('Enter');
|
||||||
|
await expect(native.locator('main')).toBeFocused();
|
||||||
|
await expect(native.locator('.content-focus-cue')).toBeVisible();
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
@@ -120,6 +120,19 @@ export async function expectInsetFocusIndicator(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectContentFocusCue(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator('#portfolio-content')).toBeFocused();
|
||||||
|
const cue = page.locator('.content-focus-cue');
|
||||||
|
await expect(cue).toBeVisible();
|
||||||
|
expect(await page.locator('#portfolio-content').evaluate(element => ({
|
||||||
|
outline: getComputedStyle(element).outlineStyle,
|
||||||
|
shadow: getComputedStyle(element).boxShadow,
|
||||||
|
}))).toEqual({ outline: 'none', shadow: 'none' });
|
||||||
|
const box = await cue.boundingBox();
|
||||||
|
expect(box!.width).toBeLessThan(240);
|
||||||
|
expect(box!.height).toBeLessThan(60);
|
||||||
|
}
|
||||||
|
|
||||||
export async function revealNavigationLinks(page: Page): Promise<void> {
|
export async function revealNavigationLinks(page: Page): Promise<void> {
|
||||||
const summary = page.locator(".site-navigation summary");
|
const summary = page.locator(".site-navigation summary");
|
||||||
if (!(await summary.isVisible())) return;
|
if (!(await summary.isVisible())) return;
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ describe('artifact verification', () => {
|
|||||||
{
|
{
|
||||||
name: 'identity rendered only inside a template',
|
name: 'identity rendered only inside a template',
|
||||||
mutate: (html: string) => html.replace(
|
mutate: (html: string) => html.replace(
|
||||||
/(<h1[^>]*>Luca Pîslaru<\/h1><p class="hero-journey__role">System Administrator & Full-Stack Developer<\/p>)/u,
|
/(<h1\b[^>]*>[\s\S]*?<\/h1><p class="hero-journey__role">System Administrator & Full-Stack Developer<\/p>)/u,
|
||||||
'<template>$1</template>',
|
'<template>$1</template>',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -456,7 +456,9 @@ describe('artifact verification', () => {
|
|||||||
},
|
},
|
||||||
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
|
])('rejects $name as non-visible semantic evidence', async ({ mutate }) => {
|
||||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||||
await writeFile(join(directory, 'index.html'), mutate(html, inputs));
|
const mutated = mutate(html, inputs);
|
||||||
|
expect(mutated).not.toBe(html);
|
||||||
|
await writeFile(join(directory, 'index.html'), mutated);
|
||||||
|
|
||||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
|
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/visible semantic|visible h1|visible navigation|visible section|connected/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('semantic portfolio document', () => {
|
|||||||
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
]);
|
]);
|
||||||
expect([...document.querySelectorAll('main > section')].every((section) => section.hasAttribute('aria-labelledby'))).toBe(true);
|
expect([...document.querySelectorAll('main > section')].every((section) => section.hasAttribute('aria-labelledby'))).toBe(true);
|
||||||
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
expect([...document.querySelectorAll('main > section h2')].map((heading) => heading.textContent)).toEqual([
|
||||||
'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
|
'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
|
||||||
]);
|
]);
|
||||||
expect([...document.querySelectorAll('main > section')].map((section) => section.getAttribute('data-layout'))).toEqual([
|
expect([...document.querySelectorAll('main > section')].map((section) => section.getAttribute('data-layout'))).toEqual([
|
||||||
@@ -132,7 +132,7 @@ describe('semantic portfolio document', () => {
|
|||||||
|
|
||||||
const document = await renderedDocumentFor(reorderedInputs);
|
const document = await renderedDocumentFor(reorderedInputs);
|
||||||
|
|
||||||
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
expect([...document.querySelectorAll('main > section h2')].map((heading) => heading.textContent)).toEqual([
|
||||||
'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
|
'Profile', 'Experience', 'Infrastructure', 'Selected builds', 'Additional work', 'Toolkit', 'Contact',
|
||||||
]);
|
]);
|
||||||
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
|
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe('contact disclosure', () => {
|
|||||||
await wrapper.get('.contact-email__action').trigger('focusin');
|
await wrapper.get('.contact-email__action').trigger('focusin');
|
||||||
expect(wrapper.get('a.contact-link').attributes('href')).toBe('mailto:luca.pislaru@gmail.com');
|
expect(wrapper.get('a.contact-link').attributes('href')).toBe('mailto:luca.pislaru@gmail.com');
|
||||||
expect(wrapper.get('a.contact-link').text()).toBe('Email Me');
|
expect(wrapper.get('a.contact-link').text()).toBe('Email Me');
|
||||||
|
expect(wrapper.get('a.contact-link').attributes('target')).toBe('_blank');
|
||||||
|
expect(wrapper.get('a.contact-link').attributes('rel')).toBe('noopener noreferrer');
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
126
tests/unit/sun-arrival.test.ts
Normal file
126
tests/unit/sun-arrival.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
|
import { createSun } from '@/scene/world/sun';
|
||||||
|
import { createMutableChapterPalette } from '@/scene/world/palette';
|
||||||
|
|
||||||
|
interface Halo {
|
||||||
|
scale: { x: number };
|
||||||
|
material: { opacity: number; color: { getHex(): number } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('destination sun', () => {
|
||||||
|
it('keeps a distinct destination glow after the one-shot finale, without allocating on updates', () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sun = createSun(resources);
|
||||||
|
const palette = createMutableChapterPalette();
|
||||||
|
const halo = sun.group.getObjectByName('horizon-halo') as Halo;
|
||||||
|
sun.update(palette, 1, 10, 0, false);
|
||||||
|
const initial = { width: halo.scale.x, opacity: halo.material.opacity, color: halo.material.color.getHex() };
|
||||||
|
const allocation = resources.snapshot();
|
||||||
|
sun.update(palette, 1, 10, 0, true);
|
||||||
|
sun.update(palette, 1, 11, 0, true);
|
||||||
|
expect(halo.scale.x).toBeGreaterThan(initial.width);
|
||||||
|
expect(halo.material.opacity).toBeGreaterThan(initial.opacity);
|
||||||
|
expect(halo.material.color.getHex()).not.toBe(initial.color);
|
||||||
|
sun.update(palette, 1, 15, 0, true);
|
||||||
|
const settled = { width: halo.scale.x, opacity: halo.material.opacity, color: halo.material.color.getHex() };
|
||||||
|
expect(settled.color).not.toBe(initial.color);
|
||||||
|
expect(settled.opacity).toBeGreaterThan(initial.opacity);
|
||||||
|
for (const time of [20, 100]) {
|
||||||
|
sun.update(palette, 1, time, 0, true);
|
||||||
|
expect(halo.scale.x).toBe(settled.width);
|
||||||
|
expect(halo.material.opacity).toBe(settled.opacity);
|
||||||
|
expect(halo.material.color.getHex()).toBe(settled.color);
|
||||||
|
}
|
||||||
|
sun.update(palette, 0.8, 101, 0, false);
|
||||||
|
sun.update(palette, 1, 102, 0, true);
|
||||||
|
sun.update(palette, 1, 103, 0, true);
|
||||||
|
expect(halo.scale.x).toBeGreaterThan(initial.width);
|
||||||
|
expect(resources.snapshot()).toEqual(allocation);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws three batched meteor trails only during arrival and re-arms after leaving', () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sun = createSun(resources);
|
||||||
|
const palette = createMutableChapterPalette();
|
||||||
|
const meteors = sun.group.getObjectByName('arrival-meteors') as {
|
||||||
|
visible: boolean;
|
||||||
|
geometry: { getAttribute(name: string): { count: number; array: Float32Array } };
|
||||||
|
material: { uniforms: { uTime: { value: number } } };
|
||||||
|
} | undefined;
|
||||||
|
expect(meteors).toBeDefined();
|
||||||
|
sun.update(palette, 1, 0, 0, false);
|
||||||
|
expect(meteors!.visible).toBe(true);
|
||||||
|
expect(meteors!.material.uniforms.uTime.value).toBe(-1);
|
||||||
|
sun.update(palette, 1, 0.1, 0, false);
|
||||||
|
expect(meteors!.visible).toBe(false);
|
||||||
|
const allocation = resources.snapshot();
|
||||||
|
const positions = meteors!.geometry.getAttribute('position');
|
||||||
|
expect(positions.count).toBeLessThanOrEqual(18);
|
||||||
|
sun.update(palette, 1, 1, 0, true);
|
||||||
|
sun.update(palette, 1, 2, 0, true);
|
||||||
|
expect(meteors!.visible).toBe(true);
|
||||||
|
expect(meteors!.material.uniforms.uTime.value).toBe(1);
|
||||||
|
sun.update(palette, 1, 5.1, 0, true);
|
||||||
|
expect(meteors!.visible).toBe(false);
|
||||||
|
sun.update(palette, 1, 50, 0, true);
|
||||||
|
expect(meteors!.visible).toBe(false);
|
||||||
|
sun.update(palette, 0.8, 51, 0, false);
|
||||||
|
sun.update(palette, 1, 52, 0, true);
|
||||||
|
sun.update(palette, 1, 53, 0, true);
|
||||||
|
expect(meteors!.visible).toBe(true);
|
||||||
|
sun.update(palette, 0.8, 53.1, 0, false);
|
||||||
|
expect(meteors!.visible).toBe(false);
|
||||||
|
expect(meteors!.geometry.getAttribute('position').array).toBe(positions.array);
|
||||||
|
expect(resources.snapshot()).toEqual(allocation);
|
||||||
|
resources.dispose();
|
||||||
|
expect(resources.snapshot().owned.geometry).toBe(0);
|
||||||
|
expect(resources.snapshot().owned.material).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the sun rim for an expanding ring that repeats every three seconds only at the destination', () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sun = createSun(resources);
|
||||||
|
const palette = createMutableChapterPalette();
|
||||||
|
const limb = sun.group.getObjectByName('sun-limb-glow') as {
|
||||||
|
material: { uniforms: Record<string, { value: number }> };
|
||||||
|
};
|
||||||
|
const uniforms = limb.material.uniforms;
|
||||||
|
expect(uniforms.uRingRadius).toBeDefined();
|
||||||
|
sun.update(palette, 1, 0, 0, true);
|
||||||
|
sun.update(palette, 1, 1, 0, true);
|
||||||
|
const firstRadius = uniforms.uRingRadius!.value;
|
||||||
|
const firstOpacity = uniforms.uRingOpacity!.value;
|
||||||
|
expect(uniforms.uRingOpacity!.value).toBeGreaterThan(0);
|
||||||
|
sun.update(palette, 1, 2, 0, true);
|
||||||
|
expect(uniforms.uRingRadius!.value).toBeGreaterThan(firstRadius);
|
||||||
|
sun.update(palette, 1, 3.5, 0, true);
|
||||||
|
expect(uniforms.uRingOpacity!.value).toBe(0);
|
||||||
|
for (const time of [4, 7, 40]) {
|
||||||
|
sun.update(palette, 1, time, 0, true);
|
||||||
|
expect(uniforms.uRingRadius!.value).toBeCloseTo(firstRadius);
|
||||||
|
expect(uniforms.uRingOpacity!.value).toBeCloseTo(firstOpacity);
|
||||||
|
}
|
||||||
|
sun.update(palette, 0.8, 40.1, 0, false);
|
||||||
|
expect(uniforms.uRingOpacity!.value).toBe(0);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pulse from route progress alone and cancels when leaving Contact', () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sun = createSun(resources);
|
||||||
|
const palette = createMutableChapterPalette();
|
||||||
|
const halo = sun.group.getObjectByName('horizon-halo') as Halo;
|
||||||
|
sun.update(palette, 1, 0);
|
||||||
|
const width = halo.scale.x;
|
||||||
|
sun.update(palette, 1, 1);
|
||||||
|
expect(halo.scale.x).toBe(width);
|
||||||
|
sun.update(palette, 1, 2, 0, true);
|
||||||
|
sun.update(palette, 1, 3, 0, true);
|
||||||
|
expect(halo.scale.x).toBeGreaterThan(width);
|
||||||
|
sun.update(palette, 1, 3.1, 0, false);
|
||||||
|
expect(halo.scale.x).toBe(width);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user