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>

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>