feat: integrate accessible journey world and controls

This commit is contained in:
2026-09-02 05:07:24 +03:00
parent fd8347fbdc
commit 190ac1ce62
10 changed files with 755 additions and 15 deletions

View File

@@ -5,6 +5,7 @@ import './styles/tokens.css';
import './styles/base.css';
import './styles/layout.css';
import './styles/components.css';
import './styles/world.css';
import './styles/print.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();

View File

@@ -1,14 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import ContactDestination from './ContactDestination.vue';
import DriveControls from './DriveControls.vue';
import ExperienceTimeline from './ExperienceTimeline.vue';
import InfrastructureDiagram from './InfrastructureDiagram.vue';
import JourneyHud from './JourneyHud.vue';
import PortfolioSection from './PortfolioSection.vue';
import ProjectCard from './ProjectCard.vue';
import ProjectCaseStudy from './ProjectCaseStudy.vue';
import SceneCanvas from './SceneCanvas.vue';
import SiteNavigation from './SiteNavigation.vue';
import ToolkitGrid from './ToolkitGrid.vue';
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
import type { PortfolioContent, SectionId } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
const props = defineProps<{ content: Readonly<PortfolioContent> }>();
@@ -27,15 +36,98 @@ const sectionLabel = (id: SectionId): string => {
};
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
const motion = useMotionPreferences();
const navigation = useKeyboardNavigation(canonicalSectionIds);
const journey = useJourneySections(
canonicalSectionIds,
navigation.state,
computed(() => motion.policy.value.allowSceneRequest),
);
const sceneState = shallowRef<SceneHostState>('idle');
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
const controlsReady = shallowRef(false);
const adaptive = useAdaptiveQuality({
maximum: computed(() => motion.policy.value.maximumTier),
sceneState,
pageLifecycle,
});
const actualQuality = computed(() => adaptive.snapshot.value.actual);
const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId));
const serverDefaultPolicy: MotionPolicy = {
allowSceneRequest: false,
allowAutomaticVideo: false,
maximumTier: 'high',
reason: 'full',
};
const displayedPolicy = computed(() => controlsReady.value ? motion.policy.value : serverDefaultPolicy);
const displayedQuality = computed<QualityTier>(() => controlsReady.value ? actualQuality.value : 'high');
const displayedSceneState = computed(() => controlsReady.value ? sceneState.value : 'running');
let removePageListeners = (): void => {};
watch(
() => journey.value.activeSectionId,
(id) => navigation.notePassiveSection(id),
);
watch(
() => motion.policy.value.maximumTier,
(tier, previous) => {
if (tier !== previous) adaptive.setMaximum(tier, 'manual');
},
);
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
motion.setMaximumQuality(tier);
adaptive.setMaximum(tier, 'manual');
};
const enableMotion = (): void => {
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
motion.setMaximumQuality(tier);
motion.setPaused(false);
adaptive.setMaximum(tier, 'manual');
};
onMounted(() => {
controlsReady.value = true;
const onPageHide = (event: PageTransitionEvent): void => {
pageLifecycle.value = event.persisted ? 'frozen' : 'disposed';
};
const onPageShow = (): void => { pageLifecycle.value = 'active'; };
window.addEventListener('pagehide', onPageHide);
window.addEventListener('pageshow', onPageShow);
removePageListeners = () => {
window.removeEventListener('pagehide', onPageHide);
window.removeEventListener('pageshow', onPageShow);
};
});
onUnmounted(() => removePageListeners());
</script>
<template>
<a class="skip-link" href="#portfolio-content">Skip to portfolio content</a>
<SceneCanvas
:journey="journey"
:policy="motion.policy.value"
:quality="actualQuality"
:scene-description="content.sceneDescription"
@state-change="sceneState = $event"
/>
<JourneyHud
:journey="journey"
:section-label="activeSectionLabel"
:quality="displayedQuality"
/>
<header class="site-header">
<h1>{{ content.identity.name }}</h1>
<p>{{ content.identity.role }}</p>
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
<SiteNavigation :items="canonicalNavigation" />
<SiteNavigation
:items="canonicalNavigation"
:journey="journey"
:on-section-link-click="navigation.onSectionLinkClick"
/>
</header>
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
@@ -79,4 +171,13 @@ const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id
</footer>
</PortfolioSection>
</main>
<DriveControls
:policy="displayedPolicy"
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
:scene-status="displayedSceneState"
@pause="motion.setPaused(true)"
@resume="motion.setPaused(false)"
@enable="enableMotion"
@quality-change="setQuality"
/>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
const props = defineProps<{
policy: MotionPolicy;
maximumQuality: QualityTier;
sceneStatus: string;
}>();
const emit = defineEmits<{
pause: [];
resume: [];
enable: [];
qualityChange: [quality: QualityTier];
}>();
const action = computed(() => {
if (props.policy.reason === 'visitor-pause') return { label: 'Resume motion', event: 'resume' as const };
if (props.policy.reason === 'saved-static') return { label: 'Enable motion', event: 'enable' as const };
if (props.policy.reason === 'full') return { label: 'Pause motion', event: 'pause' as const };
return null;
});
const statusText = computed(() => {
if (props.policy.reason === 'system-reduce') return 'Motion: static — reduced motion requested';
if (props.policy.reason === 'data-saver') return 'Motion: static — data saver is active';
if (props.policy.reason === 'visitor-pause') return 'Motion: paused';
if (props.policy.reason === 'saved-static') return 'Motion: static';
if (props.sceneStatus === 'failed' || props.sceneStatus === 'static') return 'Motion: static — scene unavailable';
if (props.sceneStatus === 'loading' || props.sceneStatus === 'idle') return 'Motion: preparing';
return 'Motion: active';
});
const runAction = (): void => {
const event = action.value?.event;
if (event === 'pause') emit('pause');
else if (event === 'resume') emit('resume');
else if (event === 'enable') emit('enable');
};
const changeQuality = (event: Event): void => {
const value = (event.currentTarget as HTMLSelectElement).value;
if (value === 'high' || value === 'medium' || value === 'low' || value === 'static') {
emit('qualityChange', value);
}
};
</script>
<template>
<div class="drive-controls">
<button v-if="action" type="button" @click="runAction">{{ action.label }}</button>
<label>
<span>Quality</span>
<select :value="maximumQuality" aria-label="Quality control" @change="changeQuality">
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="static">Static</option>
</select>
</label>
<p class="drive-controls__status" role="status">{{ statusText }}</p>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { JourneySnapshot } from '@/journey/types';
import type { QualityTier } from '@/journey/motion-policy';
const props = defineProps<{
journey: JourneySnapshot;
sectionLabel: string;
quality: QualityTier;
}>();
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`);
const qualityLabel = computed(() => props.quality.charAt(0).toUpperCase() + props.quality.slice(1));
</script>
<template>
<aside class="journey-hud" aria-hidden="true">
<span class="journey-hud__route">ROUTE {{ routePercent }}</span>
<span class="journey-hud__section">{{ sectionLabel }}</span>
<span class="journey-hud__quality">{{ qualityLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
</aside>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, onMounted, ref, toRef, watch } from 'vue';
import { useSceneController, type SceneHostState } from '@/composables/useSceneController';
import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
const props = defineProps<{
journey: JourneySnapshot;
policy: MotionPolicy;
quality: QualityTier;
sceneDescription: string;
}>();
const emit = defineEmits<{
stateChange: [state: SceneHostState];
}>();
const canvas = ref<HTMLCanvasElement | null>(null);
const clientReady = ref(false);
const journey = toRef(props, 'journey');
const policy = toRef(props, 'policy');
const quality = toRef(props, 'quality');
const scene = useSceneController({ canvas, journey, policy, quality });
const showCanvas = computed(() => (
clientReady.value
&& policy.value.allowSceneRequest
&& quality.value !== 'static'
&& !scene.error.value
));
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
onMounted(() => { clientReady.value = true; });
</script>
<template>
<p id="journey-scene-description" class="visually-hidden">{{ sceneDescription }}</p>
<div class="journey-world" aria-hidden="true" :data-scene-state="scene.state">
<img class="journey-poster" alt="" width="1600" height="900">
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
</div>
<slot />
</template>

View File

@@ -1,20 +1,12 @@
<script setup lang="ts">
import { watch } from "vue";
import type { SectionId } from "@/content/types";
import { useJourneySections } from "@/composables/useJourneySections";
import { useKeyboardNavigation } from "@/composables/useKeyboardNavigation";
import type { JourneySnapshot } from '@/journey/types';
const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>;
journey: JourneySnapshot;
onSectionLinkClick: (event: MouseEvent) => void;
}>();
const canonicalIds = props.items.map((item) => item.id);
const navigation = useKeyboardNavigation(canonicalIds);
const journey = useJourneySections(canonicalIds, navigation.state);
watch(
() => journey.value.activeSectionId,
(id) => navigation.notePassiveSection(id),
);
</script>
<template>
@@ -24,9 +16,9 @@ watch(
<a
:href="`#${item.id}`"
:aria-current="
journey.activeSectionId === item.id ? 'page' : undefined
props.journey.activeSectionId === item.id ? 'page' : undefined
"
@click="navigation.onSectionLinkClick"
@click="props.onSectionLinkClick"
>{{ item.label }}</a
>
</li>

View File

@@ -0,0 +1,141 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
type WatchStopHandle,
} from 'vue';
import { createSceneController } from '@/scene/create-scene-controller';
import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { SceneController, SceneControllerSnapshot } from '@/scene/types';
export type SceneHostState = 'idle' | 'loading' | 'running' | 'paused' | 'static' | 'failed' | 'disposed';
export interface SceneControllerBinding {
state: Readonly<Ref<SceneHostState>>;
snapshot: Readonly<Ref<SceneControllerSnapshot | null>>;
error: Readonly<Ref<boolean>>;
}
export interface SceneControllerSources {
canvas: Readonly<Ref<HTMLCanvasElement | null>>;
journey: Readonly<Ref<JourneySnapshot>>;
policy: Readonly<Ref<MotionPolicy>>;
quality: Readonly<Ref<QualityTier>>;
}
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
const state = shallowRef<SceneHostState>('idle');
const snapshot = shallowRef<SceneControllerSnapshot | null>(null);
const error = shallowRef(false);
let controller: SceneController | null = null;
let importAbort: AbortController | null = null;
let generation = 0;
let mounted = false;
let stopWatch: WatchStopHandle = () => {};
let removePageHide = (): void => {};
const publish = (): void => {
snapshot.value = controller?.snapshot() ?? null;
const next = snapshot.value?.state.kind;
state.value = next === undefined
? (error.value ? 'failed' : 'static')
: next === 'lost' || next === 'restoring' ? 'loading' : next;
};
const disposeActive = (nextState: SceneHostState): void => {
generation += 1;
importAbort?.abort();
importAbort = null;
const active = controller;
controller = null;
active?.dispose();
snapshot.value = active?.snapshot() ?? null;
state.value = nextState;
};
const reconcile = async (): Promise<void> => {
if (!mounted) return;
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
const canvas = sources.canvas.value;
if (!allowed || canvas === null) {
disposeActive(allowed ? 'idle' : 'static');
return;
}
if (controller !== null) {
controller.update(sources.journey.value);
await controller.setQuality(sources.quality.value);
publish();
return;
}
const currentGeneration = ++generation;
const abort = new AbortController();
importAbort = abort;
error.value = false;
state.value = 'loading';
try {
const { createProceduralSceneGeneration } = await import('@/scene/world/environment');
if (
abort.signal.aborted
|| !mounted
|| currentGeneration !== generation
|| !sources.policy.value.allowSceneRequest
|| sources.quality.value === 'static'
|| sources.canvas.value !== canvas
) return;
const next = createSceneController({
canvas,
quality: sources.quality.value,
build: createProceduralSceneGeneration,
motionAllowed: () => sources.policy.value.allowSceneRequest,
signal: abort.signal,
});
controller = next;
next.update(sources.journey.value);
await next.start();
if (controller !== next || currentGeneration !== generation) {
next.dispose();
return;
}
const kind = next.snapshot().state.kind;
error.value = kind === 'static';
publish();
} catch {
if (!abort.signal.aborted && currentGeneration === generation) {
error.value = true;
disposeActive('failed');
}
} finally {
if (importAbort === abort) importAbort = null;
}
};
onMounted(() => {
mounted = true;
const onPageHide = (event: PageTransitionEvent): void => {
if (!event.persisted) disposeActive('disposed');
};
window.addEventListener('pagehide', onPageHide);
removePageHide = () => window.removeEventListener('pagehide', onPageHide);
stopWatch = watch(
[sources.canvas, sources.policy, sources.quality, sources.journey],
() => { void reconcile(); },
{ immediate: true, flush: 'post' },
);
});
onUnmounted(() => {
mounted = false;
stopWatch();
removePageHide();
disposeActive('disposed');
});
return { state: readonly(state), snapshot: readonly(snapshot), error: readonly(error) };
}

158
src/styles/world.css Normal file
View File

@@ -0,0 +1,158 @@
.visually-hidden {
position: absolute !important;
inline-size: 1px !important;
block-size: 1px !important;
margin: -1px !important;
overflow: hidden !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
}
.journey-world {
position: fixed;
z-index: 0;
inset: 0;
overflow: hidden;
pointer-events: none;
background: #040713;
}
.journey-poster,
.journey-canvas {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
}
.journey-poster {
object-fit: cover;
background:
radial-gradient(circle at 50% 53%, rgb(247 105 115 / 82%) 0 7%, transparent 7.3%),
linear-gradient(165deg, transparent 0 60%, rgb(73 198 215 / 34%) 60.2% 60.5%, transparent 60.7%),
linear-gradient(195deg, transparent 0 60%, rgb(73 198 215 / 34%) 60.2% 60.5%, transparent 60.7%),
linear-gradient(180deg, #050817 0 48%, #24142c 67%, #080c17 100%);
}
.journey-canvas {
max-inline-size: none;
}
.portfolio-shell {
position: relative;
z-index: 1;
}
.site-header {
background: rgb(7 13 23 / 96%);
}
.portfolio-section {
background: rgb(16 23 34 / 94%);
}
.journey-hud {
position: fixed;
z-index: 4;
inset: auto 1rem 1rem auto;
display: grid;
grid-template-columns: repeat(2, auto);
gap: 0.25rem 0.9rem;
min-inline-size: 12rem;
padding: 0.75rem 0.9rem;
border: 1px solid rgb(128 217 230 / 38%);
background: rgb(4 9 18 / 84%);
color: var(--text-muted);
font: 600 var(--step--1)/1.2 var(--mono);
letter-spacing: 0.06em;
pointer-events: none;
}
.journey-hud__section,
.journey-hud__arrival {
color: var(--cyan);
}
.journey-hud__arrival {
grid-column: 1 / -1;
}
.drive-controls {
position: fixed;
z-index: 5;
inset: auto auto 1rem 1rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
max-inline-size: calc(100vw - 2rem);
padding: 0.55rem;
border: 1px solid rgb(128 217 230 / 32%);
background: rgb(4 9 18 / 88%);
font-family: var(--mono);
font-size: var(--step--1);
}
.drive-controls button,
.drive-controls select {
min-block-size: 44px;
border: 1px solid var(--line-strong);
background: #101925;
color: var(--text);
}
.drive-controls button {
padding-inline: 0.8rem;
}
.drive-controls label {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.drive-controls select {
padding-inline: 0.45rem;
}
.drive-controls__status {
color: var(--text-muted);
}
@media (max-width: 767px) {
.journey-hud {
inset: 0.5rem 0.5rem auto auto;
min-inline-size: 0;
padding: 0.5rem;
font-size: 0.72rem;
}
.journey-hud__quality {
display: none;
}
.drive-controls {
inset: auto 0.5rem 0.5rem;
}
.drive-controls__status {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.journey-poster {
background:
radial-gradient(circle at 50% 53%, rgb(247 105 115 / 48%) 0 7%, transparent 7.3%),
linear-gradient(180deg, #050817 0 48%, #24142c 67%, #080c17 100%);
}
}
@media print {
.journey-world,
.journey-hud,
.drive-controls,
#journey-scene-description {
display: none !important;
}
}

View File

@@ -0,0 +1,50 @@
import { expect, test } from '../helpers/serve-dist';
test('keeps the decorative world separate from the semantic reading path', async ({ page, distUrl }) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('#journey-scene-description')).toContainText('night');
await expect(page.locator('.journey-world')).toHaveAttribute('aria-hidden', 'true');
await expect(page.locator('.journey-poster')).toHaveAttribute('alt', '');
await expect(page.locator('.journey-poster')).toHaveAttribute('width', '1600');
await expect(page.locator('main#portfolio-content')).toBeVisible();
});
test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
const quality = page.getByLabel('Quality control');
await expect(quality).toBeVisible();
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
await expect(page.getByRole('status')).toContainText('Motion:');
await page.getByRole('button', { name: 'Pause motion' }).click();
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await page.getByRole('button', { name: 'Resume motion' }).click();
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
await quality.selectOption('static');
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.getByRole('status')).toContainText('Motion: static');
});
test('never creates a canvas when the system requests reduced motion', async ({ page, distUrl }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.journey-canvas')).toHaveCount(0);
await expect(page.getByRole('status')).toContainText('reduced motion requested');
await expect(page.getByLabel('Quality control')).toBeVisible();
});
test('shows ARRIVED from the authoritative section snapshot at Contact', async ({ page, distUrl }) => {
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
await page.locator('nav a[href="#contact"]').click();
await expect(page.locator('.journey-hud')).toContainText('Contact');
await expect(page.locator('.journey-hud')).toContainText('ARRIVED');
await expect(page.locator('.journey-hud')).toContainText('100%');
});

View File

@@ -0,0 +1,168 @@
import { mount, flushPromises } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import SceneCanvas from '@/components/SceneCanvas.vue';
import DriveControls from '@/components/DriveControls.vue';
import JourneyHud from '@/components/JourneyHud.vue';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { JourneySnapshot } from '@/journey/types';
const sceneMocks = vi.hoisted(() => ({
build: vi.fn(async () => ({ render: vi.fn(), dispose: vi.fn() })),
}));
vi.mock('@/scene/world/environment', () => ({
createProceduralSceneGeneration: sceneMocks.build,
}));
const journey = (overrides: Partial<JourneySnapshot> = {}): JourneySnapshot => ({
scrollY: 0,
routeProgress: 0.42,
direction: 1,
acceleration: 0.3,
driveSpeed: 0.6,
activeSectionId: 'infrastructure',
chapter: 'infrastructure',
sign: null,
arrived: false,
...overrides,
});
const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
allowSceneRequest: true,
allowAutomaticVideo: true,
maximumTier: 'high',
reason: 'full',
...overrides,
});
describe('SceneCanvas', () => {
beforeEach(() => sceneMocks.build.mockClear());
afterEach(() => vi.restoreAllMocks());
it.each([
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
['saved Static mode', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' })],
])('does not request or render the scene for %s', async (_name, blockedPolicy) => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: blockedPolicy,
quality: 'static',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
expect(sceneMocks.build).not.toHaveBeenCalled();
expect(wrapper.findAll('canvas')).toHaveLength(0);
expect(wrapper.get('img').attributes()).toMatchObject({ alt: '', width: '1600', height: '900' });
expect(wrapper.text()).toContain('A decorative night road behind the portfolio content.');
wrapper.unmount();
});
it('owns exactly one decorative canvas when full motion is allowed', async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'high',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
expect(wrapper.findAll('canvas')).toHaveLength(1);
expect(wrapper.get('canvas').attributes('aria-hidden')).toBe('true');
expect(wrapper.get('.journey-world').attributes('aria-hidden')).toBe('true');
wrapper.unmount();
});
it('falls back to the poster without obscuring semantic content after scene failure', async () => {
sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable'));
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'medium',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
slots: { default: '<main><h1>Semantic portfolio</h1></main>' },
});
await flushPromises();
expect(wrapper.get('img').isVisible()).toBe(true);
expect(wrapper.findAll('canvas')).toHaveLength(0);
expect(wrapper.text()).toContain('Semantic portfolio');
wrapper.unmount();
});
it('aborts and removes the canvas on a Static transition', async () => {
const wrapper = mount(SceneCanvas, {
props: {
journey: journey(),
policy: policy(),
quality: 'high',
sceneDescription: 'A decorative night road behind the portfolio content.',
},
});
await flushPromises();
expect(wrapper.findAll('canvas')).toHaveLength(1);
await wrapper.setProps({
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'saved-static' }),
quality: 'static',
});
await nextTick();
expect(wrapper.findAll('canvas')).toHaveLength(0);
expect(wrapper.get('img').isVisible()).toBe(true);
wrapper.unmount();
});
});
describe('DriveControls', () => {
const mountControls = (motionPolicy: MotionPolicy, maximum: QualityTier = 'high') => mount(DriveControls, {
props: { policy: motionPolicy, maximumQuality: maximum, sceneStatus: 'running' },
});
it('uses exact native control names and a visible non-color status', async () => {
const wrapper = mountControls(policy());
expect(wrapper.get('button').text()).toBe('Pause motion');
expect(wrapper.get('select').attributes('aria-label')).toBe('Quality control');
expect(wrapper.text()).toContain('Motion: active');
await wrapper.get('button').trigger('click');
expect(wrapper.emitted('pause')).toHaveLength(1);
});
it.each([
['visitor-pause', 'Resume motion'],
['saved-static', 'Enable motion'],
] as const)('offers the correct recovery action for %s', (reason, label) => {
const wrapper = mountControls(policy({ allowSceneRequest: false, maximumTier: 'static', reason }));
expect(wrapper.get('button').text()).toBe(label);
});
});
describe('JourneyHud', () => {
it('renders route percent, current section, quality and arrival from one snapshot', () => {
const wrapper = mount(JourneyHud, {
props: {
journey: journey({ routeProgress: 1, activeSectionId: 'contact', arrived: true }),
sectionLabel: 'Contact',
quality: 'low',
},
});
expect(wrapper.text()).toContain('100%');
expect(wrapper.text()).toContain('Contact');
expect(wrapper.text()).toContain('Low');
expect(wrapper.text()).toContain('ARRIVED');
expect(wrapper.attributes('aria-hidden')).toBe('true');
});
});