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:
@@ -39,7 +39,7 @@ describe('semantic portfolio document', () => {
|
||||
'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 > 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',
|
||||
]);
|
||||
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);
|
||||
|
||||
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',
|
||||
]);
|
||||
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');
|
||||
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').attributes('target')).toBe('_blank');
|
||||
expect(wrapper.get('a.contact-link').attributes('rel')).toBe('noopener noreferrer');
|
||||
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