fix: close phase 2 journey blockers

This commit is contained in:
2026-09-02 06:04:20 +03:00
parent a86767cdc6
commit 77895c01ff
19 changed files with 378 additions and 26 deletions

View File

@@ -78,20 +78,28 @@ watch(
watch( watch(
() => motion.policy.value.maximumTier, () => motion.policy.value.maximumTier,
(tier, previous) => { (tier, previous) => {
if (tier !== previous) adaptive.setMaximum(tier, 'manual'); if (tier !== previous) {
adaptive.setMaximum(tier, controlsReady.value ? 'resize' : 'manual');
}
}, },
{ flush: 'sync' },
); );
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => { const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
motion.setMaximumQuality(tier); motion.setMaximumQuality(tier);
adaptive.setMaximum(tier, 'manual'); adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
}; };
const enableMotion = (): void => { const enableMotion = (): void => {
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value; const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
motion.setMaximumQuality(tier); motion.setMaximumQuality(tier);
motion.setPaused(false); motion.setPaused(false);
adaptive.setMaximum(tier, 'manual'); adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const resumeMotion = (): void => {
motion.setPaused(false);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
}; };
onMounted(() => { onMounted(() => {
@@ -118,6 +126,7 @@ onUnmounted(() => removePageListeners());
:policy="motion.policy.value" :policy="motion.policy.value"
:quality="actualQuality" :quality="actualQuality"
:scene-description="content.sceneDescription" :scene-description="content.sceneDescription"
:sample-frame="adaptive.sampleFrame"
@state-change="sceneState = $event" @state-change="sceneState = $event"
/> />
<JourneyHud <JourneyHud
@@ -185,7 +194,7 @@ onUnmounted(() => removePageListeners());
:audio-muted="audio.muted.value" :audio-muted="audio.muted.value"
:audio-volume="controlsReady ? motion.volume.value : 0.4" :audio-volume="controlsReady ? motion.volume.value : 0.4"
@pause="motion.setPaused(true)" @pause="motion.setPaused(true)"
@resume="motion.setPaused(false)" @resume="resumeMotion"
@enable="enableMotion" @enable="enableMotion"
@quality-change="setQuality" @quality-change="setQuality"
@audio-start="audio.startFromGesture" @audio-start="audio.startFromGesture"

View File

@@ -9,6 +9,7 @@ const props = defineProps<{
policy: MotionPolicy; policy: MotionPolicy;
quality: QualityTier; quality: QualityTier;
sceneDescription: string; sceneDescription: string;
sampleFrame?: (start: number, end: number) => unknown;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -20,13 +21,16 @@ const clientReady = ref(false);
const journey = toRef(props, 'journey'); const journey = toRef(props, 'journey');
const policy = toRef(props, 'policy'); const policy = toRef(props, 'policy');
const quality = toRef(props, 'quality'); const quality = toRef(props, 'quality');
const scene = useSceneController({ canvas, journey, policy, quality }); const scene = useSceneController({ canvas, journey, policy, quality, sampleFrame: props.sampleFrame });
const sceneState = computed(() => scene.state.value); const sceneState = computed(() => scene.state.value);
const sceneGeneration = computed(() => scene.snapshot.value?.currentGeneration ?? null);
const showCanvas = computed(() => ( const showCanvas = computed(() => (
clientReady.value clientReady.value
&& policy.value.allowSceneRequest
&& quality.value !== 'static'
&& !scene.error.value && !scene.error.value
&& (
(policy.value.allowSceneRequest && quality.value !== 'static')
|| (policy.value.reason === 'visitor-pause' && sceneGeneration.value !== null)
)
)); ));
watch(scene.state, (state) => emit('stateChange', state), { immediate: true }); watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
@@ -35,7 +39,12 @@ onMounted(() => { clientReady.value = true; });
<template> <template>
<p id="journey-scene-description" class="visually-hidden">{{ sceneDescription }}</p> <p id="journey-scene-description" class="visually-hidden">{{ sceneDescription }}</p>
<div class="journey-world" aria-hidden="true" :data-scene-state="sceneState"> <div
class="journey-world"
aria-hidden="true"
:data-scene-state="sceneState"
:data-scene-generation="sceneGeneration"
>
<img class="journey-poster" alt="" width="1600" height="900"> <img class="journey-poster" alt="" width="1600" height="900">
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" /> <canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
</div> </div>

View File

@@ -13,6 +13,7 @@ import {
type QualityDecision, type QualityDecision,
} from '@/journey/adaptive-quality'; } from '@/journey/adaptive-quality';
import type { QualityTier } from '@/journey/motion-policy'; import type { QualityTier } from '@/journey/motion-policy';
import { lowerTier } from '@/journey/motion-policy';
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true }; const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
@@ -70,7 +71,7 @@ export function useAdaptiveQuality(sources: AdaptiveQualitySources): AdaptiveQua
tier: QualityTier, tier: QualityTier,
reason: 'resize' | 'manual', reason: 'resize' | 'manual',
): QualityDecision | null => { ): QualityDecision | null => {
const decision = controller.setMaximum(tier, reason); const decision = controller.setMaximum(lowerTier(tier, sources.maximum.value), reason);
reconcileEpoch(); reconcileEpoch();
return decision; return decision;
}; };

View File

@@ -1,4 +1,5 @@
import { import {
computed,
onMounted, onMounted,
onUnmounted, onUnmounted,
readonly, readonly,
@@ -41,7 +42,12 @@ export function useJourneySections(
throw new Error("At least one canonical journey section is required."); throw new Error("At least one canonical journey section is required.");
} }
const drive = useScrollDrive(); const drive = useScrollDrive();
const snapshot = shallowRef<JourneySnapshot>(initialJourney(firstId)); const sectionSnapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
const snapshot = computed<JourneySnapshot>(() => ({
...sectionSnapshot.value,
...drive.value,
routeProgress: sectionSnapshot.value.arrived ? 1 : drive.value.routeProgress,
}));
let scheduleMeasurement = (): void => {}; let scheduleMeasurement = (): void => {};
let teardown = (): void => {}; let teardown = (): void => {};
@@ -100,7 +106,7 @@ export function useJourneySections(
const activeIndex = canonicalIds.indexOf(activeSectionId); const activeIndex = canonicalIds.indexOf(activeSectionId);
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex); const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
const deltaY = scrollY - previousScrollY; const deltaY = scrollY - previousScrollY;
const currentSign = snapshot.value.sign; const currentSign = sectionSnapshot.value.sign;
const signTargetId = const signTargetId =
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null; currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
const signTarget = headings.find( const signTarget = headings.find(
@@ -119,7 +125,7 @@ export function useJourneySections(
motionAllowed: motionAllowed?.value ?? true, motionAllowed: motionAllowed?.value ?? true,
}); });
const sectionState = deriveSectionState(activeSectionId); const sectionState = deriveSectionState(activeSectionId);
snapshot.value = { sectionSnapshot.value = {
...drive.value, ...drive.value,
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress, routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
...sectionState, ...sectionState,

View File

@@ -25,6 +25,7 @@ export interface SceneControllerSources {
journey: Readonly<Ref<JourneySnapshot>>; journey: Readonly<Ref<JourneySnapshot>>;
policy: Readonly<Ref<MotionPolicy>>; policy: Readonly<Ref<MotionPolicy>>;
quality: Readonly<Ref<QualityTier>>; quality: Readonly<Ref<QualityTier>>;
sampleFrame?: ((start: number, end: number) => unknown) | undefined;
} }
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding { export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
@@ -61,14 +62,26 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
if (!mounted) return; if (!mounted) return;
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static'; const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
const canvas = sources.canvas.value; const canvas = sources.canvas.value;
if (!allowed || canvas === null) { if (!allowed) {
disposeActive(allowed ? (error.value ? 'failed' : 'idle') : 'static'); if (sources.policy.value.reason === 'visitor-pause' && controller !== null) {
controller.pause();
publish();
} else {
disposeActive('static');
}
return;
}
if (canvas === null) {
disposeActive(error.value ? 'failed' : 'idle');
return; return;
} }
if (controller !== null) { if (controller !== null) {
controller.update(sources.journey.value); controller.update(sources.journey.value);
await controller.setQuality(sources.quality.value); await controller.setQuality(sources.quality.value);
if (controller.snapshot().state.kind === 'paused') {
await controller.resume();
}
publish(); publish();
return; return;
} }
@@ -94,6 +107,7 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
quality: sources.quality.value, quality: sources.quality.value,
build: createProceduralSceneGeneration, build: createProceduralSceneGeneration,
motionAllowed: () => sources.policy.value.allowSceneRequest, motionAllowed: () => sources.policy.value.allowSceneRequest,
onFrameRendered: sources.sampleFrame,
signal: abort.signal, signal: abort.signal,
}); });
controller = next; controller = next;

View File

@@ -39,6 +39,7 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
? controller.reset(input) ? controller.reset(input)
: controller.sample(input); : controller.sample(input);
resetOnNextFrame = false; resetOnNextFrame = false;
if (snapshot.value.acceleration > 0) scheduleFrame();
}; };
const scheduleFrame = (): void => { const scheduleFrame = (): void => {

View File

@@ -10,6 +10,7 @@ const DECAY_TIME_CONSTANT_MS = 420;
const CRUISE_SPEED = 0.18; const CRUISE_SPEED = 0.18;
const ACCELERATION_SPEED_RANGE = 0.82; const ACCELERATION_SPEED_RANGE = 0.82;
const BACKGROUND_GAP_MS = 250; const BACKGROUND_GAP_MS = 250;
const DECAY_SETTLE_EPSILON = 0.001;
const clamp = (value: number, minimum: number, maximum: number): number => const clamp = (value: number, minimum: number, maximum: number): number =>
Math.min(maximum, Math.max(minimum, value)); Math.min(maximum, Math.max(minimum, value));
@@ -87,6 +88,9 @@ export function createScrollDriveController(): ScrollDriveController {
: DECAY_TIME_CONSTANT_MS; : DECAY_TIME_CONSTANT_MS;
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs); const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha; smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
if (impulse === 0 && smoothedAcceleration < DECAY_SETTLE_EPSILON) {
smoothedAcceleration = 0;
}
previousSample = { ...input }; previousSample = { ...input };
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0; const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;

View File

@@ -121,7 +121,10 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
rootResources.releaseAnimationFrame(frameId); rootResources.releaseAnimationFrame(frameId);
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return; if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
try { try {
const frameStartedAt = scheduler.now();
current.runtime.render(latestJourney, now); current.runtime.render(latestJourney, now);
const frameEndedAt = scheduler.now();
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
} catch { } catch {
enterStatic('render-failure'); enterStatic('render-failure');
return; return;
@@ -332,7 +335,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
pauseReason = 'freeze'; pauseReason = 'freeze';
break; break;
case 'paused': case 'paused':
pauseReason = 'freeze'; if (pauseReason !== 'visitor') pauseReason = 'freeze';
break; break;
case 'lost': case 'lost':
clearRestoreWatchdog(); clearRestoreWatchdog();
@@ -351,11 +354,17 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted); const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
if (!persisted || overlay !== 'frozen') return; if (!persisted || overlay !== 'frozen') return;
overlay = 'active'; overlay = 'active';
const lifecycle = machine.snapshot();
if (!(options.motionAllowed?.() ?? true)) { if (!(options.motionAllowed?.() ?? true)) {
if (
lifecycle.kind === 'paused'
&& current !== null
&& !lifecycle.resumeNeedsLoad
&& pauseReason === 'visitor'
) return;
enterStatic('motion-disallowed'); enterStatic('motion-disallowed');
return; return;
} }
const lifecycle = machine.snapshot();
if (page.visibilityState !== 'visible') return; if (page.visibilityState !== 'visible') return;
if (lifecycle.kind === 'paused' && pauseReason === 'freeze') { if (lifecycle.kind === 'paused' && pauseReason === 'freeze') {
pauseReason = null; pauseReason = null;

View File

@@ -52,6 +52,7 @@ export interface SceneControllerOptions {
scheduler?: SceneScheduler; scheduler?: SceneScheduler;
page?: ScenePageEnvironment; page?: ScenePageEnvironment;
motionAllowed?: () => boolean; motionAllowed?: () => boolean;
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
signal?: AbortSignal; signal?: AbortSignal;
} }

View File

@@ -3,10 +3,11 @@ import type { SceneResourceBag } from '@/scene/resources';
export function createCar(resources: SceneResourceBag) { export function createCar(resources: SceneResourceBag) {
const group = new THREE.Group(); const group = new THREE.Group();
group.position.set(0, 0.72, 4.4); group.position.set(0, 0.69, 0.5);
group.scale.set(0.9, 0.65, 0.78);
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({ const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x111722, color: 0x202938,
roughness: 0.28, roughness: 0.28,
metalness: 0.78, metalness: 0.78,
})); }));
@@ -16,6 +17,12 @@ export function createCar(resources: SceneResourceBag) {
metalness: 0.35, metalness: 0.35,
})); }));
const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f })); const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f }));
const silhouetteMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x405875 }));
const fasciaMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x151c28,
roughness: 0.42,
metalness: 0.62,
}));
const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({ const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({
color: 0x28d8ef, color: 0x28d8ef,
transparent: true, transparent: true,
@@ -39,6 +46,38 @@ export function createCar(resources: SceneResourceBag) {
rearLight.position.set(0, 0.04, 3.08); rearLight.position.set(0, 0.04, 3.08);
group.add(rearLight); group.add(rearLight);
const rearProfile = new THREE.Shape();
rearProfile.moveTo(-2.5, -0.32);
rearProfile.lineTo(-2.32, 0.28);
rearProfile.lineTo(-1.42, 0.72);
rearProfile.lineTo(1.42, 0.72);
rearProfile.lineTo(2.32, 0.28);
rearProfile.lineTo(2.5, -0.32);
rearProfile.closePath();
const silhouetteGeometry = resources.own('geometry', new THREE.ShapeGeometry(rearProfile));
const silhouette = new THREE.Mesh(silhouetteGeometry, silhouetteMaterial);
silhouette.name = 'rear-silhouette';
silhouette.position.z = 2.98;
group.add(silhouette);
const fasciaGeometry = resources.own('geometry', new THREE.BoxGeometry(4.3, 0.38, 0.22));
const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial);
fascia.position.set(0, -0.12, 2.94);
group.add(fascia);
const shoulderGeometry = resources.own('geometry', new THREE.BoxGeometry(5.05, 0.18, 1.1));
const shoulders = new THREE.Mesh(shoulderGeometry, bodyMaterial);
shoulders.position.set(0, 0.18, 2.48);
group.add(shoulders);
const accentMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x28d8ef }));
const accentGeometry = resources.own('geometry', new THREE.BoxGeometry(0.36, 0.1, 0.08));
for (const x of [-2.02, 2.02]) {
const accent = new THREE.Mesh(accentGeometry, accentMaterial);
accent.position.set(x, -0.15, 3.11);
group.add(accent);
}
const glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2)); const glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2));
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial); const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
underglow.rotation.x = -Math.PI / 2; underglow.rotation.x = -Math.PI / 2;
@@ -60,7 +99,7 @@ export function createCar(resources: SceneResourceBag) {
group, group,
update(speed: number, acceleration: number, elapsedSeconds: number) { update(speed: number, acceleration: number, elapsedSeconds: number) {
const response = Math.min(1, Math.max(0, acceleration)); const response = Math.min(1, Math.max(0, acceleration));
group.position.y = 0.72 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018); group.position.y = 0.69 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response; group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response;
tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08); tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08);
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2); underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);

View File

@@ -19,6 +19,13 @@ const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
destination: 0x2b2035, destination: 0x2b2035,
}; };
export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
return camera;
}
export async function createProceduralSceneGeneration( export async function createProceduralSceneGeneration(
context: SceneBuildContext, context: SceneBuildContext,
): Promise<SceneGeneration> { ): Promise<SceneGeneration> {
@@ -42,9 +49,7 @@ export async function createProceduralSceneGeneration(
const background = new THREE.Color(skyColors['deep-night']); const background = new THREE.Color(skyColors['deep-night']);
scene.background = background; scene.background = background;
scene.fog = new THREE.FogExp2(0x080c18, 0.012); scene.fog = new THREE.FogExp2(0x080c18, 0.012);
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 320); const camera = createChaseCamera();
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25); const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
scene.add(ambient); scene.add(ambient);

View File

@@ -44,6 +44,8 @@
} }
.site-header { .site-header {
position: relative;
z-index: 2;
background: rgb(7 13 23 / 96%); background: rgb(7 13 23 / 96%);
} }

View File

@@ -41,7 +41,7 @@ test('persisted lifecycle events preserve paused and Static states without dupli
await page.getByRole('button', { name: 'Pause motion' }).click(); await page.getByRole('button', { name: 'Pause motion' }).click();
await dispatchPersistedPageTransition(page, 'pagehide'); await dispatchPersistedPageTransition(page, 'pagehide');
await dispatchPersistedPageTransition(page, 'pageshow'); await dispatchPersistedPageTransition(page, 'pageshow');
await expect(page.locator('.journey-canvas')).toHaveCount(0); await expect(page.locator('.journey-canvas')).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
await page.getByLabel('Quality control').selectOption('static'); await page.getByLabel('Quality control').selectOption('static');

View File

@@ -10,6 +10,37 @@ test('keeps the decorative world separate from the semantic reading path', async
await expect(page.locator('main#portfolio-content')).toBeVisible(); await expect(page.locator('main#portfolio-content')).toBeVisible();
}); });
test('keeps the desktop identity and navigation in a paint layer above the world', async ({ page, distUrl }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
const layers = await page.evaluate(() => {
const world = document.querySelector<HTMLElement>('.journey-world');
const header = document.querySelector<HTMLElement>('.site-header');
const heading = document.querySelector<HTMLElement>('.site-header h1');
const navigation = document.querySelector<HTMLElement>('.site-navigation');
if (!world || !header || !heading || !navigation) throw new Error('Missing top-screen structure');
const worldStyle = getComputedStyle(world);
const headerStyle = getComputedStyle(header);
return {
worldZ: Number(worldStyle.zIndex),
headerZ: Number(headerStyle.zIndex),
headerPosition: headerStyle.position,
headingVisible: heading.getBoundingClientRect().width > 0 && heading.getBoundingClientRect().height > 0,
navigationVisible: navigation.getBoundingClientRect().width > 0 && navigation.getBoundingClientRect().height > 0,
headerOpacity: Number.parseFloat(headerStyle.backgroundColor.match(/[\d.]+(?=\))/gu)?.at(-1) ?? '1'),
};
});
expect(layers).toMatchObject({
headerPosition: 'relative',
headingVisible: true,
navigationVisible: true,
});
expect(layers.headerZ).toBeGreaterThan(layers.worldZ);
expect(layers.headerOpacity).toBeGreaterThanOrEqual(0.94);
});
test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => { test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
@@ -17,13 +48,21 @@ test('provides native motion and quality controls with visible state', async ({
await expect(quality).toBeVisible(); await expect(quality).toBeVisible();
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
await expect(page.getByRole('status')).toContainText('Motion:'); await expect(page.getByRole('status')).toContainText('Motion:');
const canvas = page.locator('.journey-canvas');
const originalCanvas = await canvas.elementHandle();
const originalGeneration = await page.locator('.journey-world').getAttribute('data-scene-generation');
await page.getByRole('button', { name: 'Pause motion' }).click(); await page.getByRole('button', { name: 'Pause motion' }).click();
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
await expect(page.locator('.journey-canvas')).toHaveCount(0); await expect(canvas).toHaveCount(1);
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-generation', originalGeneration ?? '');
expect(await originalCanvas?.evaluate((element) => element.isConnected)).toBe(true);
await page.getByRole('button', { name: 'Resume motion' }).click(); await page.getByRole('button', { name: 'Resume motion' }).click();
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
await expect(canvas).toHaveCount(1);
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-generation', originalGeneration ?? '');
expect(await originalCanvas?.evaluate((element) => element.isConnected)).toBe(true);
await quality.selectOption('static'); await quality.selectOption('static');
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();

View File

@@ -38,7 +38,10 @@ const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
describe('SceneCanvas', () => { describe('SceneCanvas', () => {
beforeEach(() => sceneMocks.build.mockClear()); beforeEach(() => sceneMocks.build.mockClear());
afterEach(() => vi.restoreAllMocks()); afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each([ it.each([
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })], ['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
@@ -81,6 +84,36 @@ describe('SceneCanvas', () => {
wrapper.unmount(); wrapper.unmount();
}); });
it('forwards each complete scene frame interval to adaptive quality', async () => {
const callbacks = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
const id = nextFrameId++;
callbacks.set(id, callback);
return id;
}));
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
const sampleFrame = vi.fn();
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'high',
sceneDescription: 'A decorative night road behind the portfolio content.',
sampleFrame,
},
});
await flushPromises();
const frame = [...callbacks.entries()][0]!;
callbacks.delete(frame[0]);
vi.spyOn(performance, 'now').mockReturnValueOnce(2_000).mockReturnValueOnce(2_301);
frame[1](2_000);
expect(sampleFrame).toHaveBeenCalledWith(2_000, 2_301);
wrapper.unmount();
});
it('falls back to the poster without obscuring semantic content after scene failure', async () => { it('falls back to the poster without obscuring semantic content after scene failure', async () => {
sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable')); sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable'));
const wrapper = mount(SceneCanvas, { const wrapper = mount(SceneCanvas, {
@@ -123,6 +156,38 @@ describe('SceneCanvas', () => {
expect(wrapper.get('img').isVisible()).toBe(true); expect(wrapper.get('img').isVisible()).toBe(true);
wrapper.unmount(); wrapper.unmount();
}); });
it('retains the same canvas and scene generation for a visitor pause and resume', async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'high',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
const canvas = wrapper.get('canvas').element;
const runtime = await sceneMocks.build.mock.results[0]!.value;
await wrapper.setProps({
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'visitor-pause' }),
quality: 'high',
});
await flushPromises();
expect(wrapper.get('canvas').element).toBe(canvas);
expect(runtime.dispose).not.toHaveBeenCalled();
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
await wrapper.setProps({ policy: policy() });
await flushPromises();
expect(wrapper.get('canvas').element).toBe(canvas);
expect(runtime.dispose).not.toHaveBeenCalled();
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
}); });
describe('DriveControls', () => { describe('DriveControls', () => {

View File

@@ -7,6 +7,7 @@ import {
type AdaptiveQualityController, type AdaptiveQualityController,
type QualityDecision, type QualityDecision,
} from '@/journey/adaptive-quality'; } from '@/journey/adaptive-quality';
import type { QualityTier } from '@/journey/motion-policy';
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -265,6 +266,26 @@ describe('useAdaptiveQuality', () => {
expect(wrapper.vm.snapshot.epoch).toBeNull(); expect(wrapper.vm.snapshot.epoch).toBeNull();
expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true }); expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
}); });
it('never lets a visitor request bypass the device-resolved maximum', async () => {
const { useAdaptiveQuality } = await import('@/composables/useAdaptiveQuality');
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
});
const maximum = ref<QualityTier>('low');
const sceneState = ref('running');
const pageLifecycle = ref<'active'>('active');
const wrapper = mount(defineComponent({
setup: () => useAdaptiveQuality({ maximum, sceneState, pageLifecycle, now: () => 0 }),
template: '<output />',
}));
expect(wrapper.vm.setMaximum('high', 'manual')).toBeNull();
expect(wrapper.vm.snapshot).toMatchObject({ maximum: 'low', actual: 'low' });
wrapper.unmount();
});
}); });
function feedWindow( function feedWindow(

View File

@@ -89,6 +89,51 @@ describe('scene controller lifecycle', () => {
expect(harness.generations[0]?.render).toHaveBeenCalledOnce(); expect(harness.generations[0]?.render).toHaveBeenCalledOnce();
}); });
it('samples the complete scene update and render interval', async () => {
const harness = createHarness();
const renderedFrames: Array<{ start: number; end: number }> = [];
const controller = createSceneController({
...harness.options,
onFrameRendered: (start, end) => renderedFrames.push({ start, end }),
});
await controller.start();
harness.generations[0]?.render.mockImplementation(() => {
harness.clock.now += 300;
});
harness.scheduler.flushFrame(2_000);
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
});
it('retains the same complete generation and frame across a visitor pause', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
const running = controller.snapshot();
controller.pause();
expect(controller.snapshot()).toMatchObject({
state: { kind: 'paused', generation: running.currentGeneration, resumeNeedsLoad: false },
currentGeneration: running.currentGeneration,
canvasCount: 1,
rafCount: 0,
});
expect(harness.generations).toHaveLength(1);
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
await controller.resume();
expect(controller.snapshot()).toMatchObject({
state: { kind: 'running', generation: running.currentGeneration },
currentGeneration: running.currentGeneration,
canvasCount: 1,
rafCount: 1,
});
expect(harness.generations).toHaveLength(1);
});
it('copies serializable journey snapshots instead of retaining caller objects', async () => { it('copies serializable journey snapshots instead of retaining caller objects', async () => {
const harness = createHarness(); const harness = createHarness();
const controller = createSceneController(harness.options); const controller = createSceneController(harness.options);
@@ -266,7 +311,15 @@ describe('scene controller lifecycle', () => {
await controller.whenSettled(); await controller.whenSettled();
expect(controller.snapshot().overlay).toBe('active'); expect(controller.snapshot().overlay).toBe('active');
if (source === 'running' || source === 'paused') expect(controller.snapshot().state.kind).toBe('running'); if (source === 'running') expect(controller.snapshot().state.kind).toBe('running');
if (source === 'paused') {
expect(controller.snapshot()).toMatchObject({
state: { kind: 'paused', generation: 1, resumeNeedsLoad: false },
currentGeneration: 1,
rafCount: 0,
});
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
}
if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1); if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1);
if (source === 'static') expect(controller.snapshot().state.kind).toBe('static'); if (source === 'static') expect(controller.snapshot().state.kind).toBe('static');
if (start && source === 'loading') { if (start && source === 'loading') {

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources'; import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality'; import { renderQualityFor } from '@/scene/render-quality';
import { createChaseCamera } from '@/scene/world/environment';
import { createCar } from '@/scene/world/car';
import { routeLabelFor } from '@/scene/world/sign'; import { routeLabelFor } from '@/scene/world/sign';
describe('SceneResourceBag', () => { describe('SceneResourceBag', () => {
@@ -109,4 +112,48 @@ describe('scene render quality', () => {
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE'); expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
expect(routeLabelFor('contact')).toBe('07 / DESTINATION'); expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
}); });
it('keeps the chase car recognizably wide and fully inside the desktop view', () => {
const resources = new SceneResourceBag();
const camera = createChaseCamera(16 / 9);
const car = createCar(resources);
car.group.updateMatrixWorld(true);
camera.updateMatrixWorld(true);
const bounds = new THREE.Box3().setFromObject(car.group);
const projected = boxCorners(bounds).map((point) => point.project(camera));
const minX = Math.min(...projected.map((point) => point.x));
const maxX = Math.max(...projected.map((point) => point.x));
const minY = Math.min(...projected.map((point) => point.y));
const maxY = Math.max(...projected.map((point) => point.y));
expect(maxX - minX).toBeGreaterThan(0.22);
expect(minY).toBeGreaterThan(-0.98);
expect(maxY).toBeLessThan(-0.15);
const silhouette = car.group.getObjectByName('rear-silhouette');
expect(silhouette).toBeDefined();
const silhouetteBounds = new THREE.Box3().setFromObject(silhouette);
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
expect(Math.max(
silhouette.material.color.r,
silhouette.material.color.g,
silhouette.material.color.b,
)).toBeGreaterThan(0.12);
resources.dispose();
});
}); });
function boxCorners(bounds: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}) {
return [
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
];
}

View File

@@ -247,6 +247,33 @@ describe('useScrollDrive', () => {
acceleration: expect.closeTo(0.6708070121920944, 14), acceleration: expect.closeTo(0.6708070121920944, 14),
driveSpeed: expect.closeTo(0.7300617499975173, 14), driveSpeed: expect.closeTo(0.7300617499975173, 14),
}); });
expect(callbacks).toHaveLength(1);
const decayingAccelerations: number[] = [];
let frameNow = 216;
while (callbacks.size > 0 && frameNow < 10_000) {
expect(callbacks).toHaveLength(1);
const decayFrame = [...callbacks.entries()][0]!;
callbacks.delete(decayFrame[0]);
decayFrame[1](frameNow);
await nextTick();
decayingAccelerations.push(drive().acceleration);
frameNow += 16;
}
expect(decayingAccelerations.length).toBeGreaterThan(1);
expect(decayingAccelerations.every((value, index) => (
index === 0 || value <= decayingAccelerations[index - 1]!
))).toBe(true);
expect(decayingAccelerations.at(-1)).toBe(0);
expect(drive()).toMatchObject({
scrollY: 750,
routeProgress: 0.75,
direction: 0,
acceleration: 0,
driveSpeed: 0.18,
});
expect(callbacks).toHaveLength(0);
window.dispatchEvent(new Event('scroll')); window.dispatchEvent(new Event('scroll'));
expect(callbacks).toHaveLength(1); expect(callbacks).toHaveLength(1);