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

@@ -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>