feat: integrate accessible journey world and controls
This commit is contained in:
@@ -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> }>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
src/components/DriveControls.vue
Normal file
64
src/components/DriveControls.vue
Normal 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>
|
||||
23
src/components/JourneyHud.vue
Normal file
23
src/components/JourneyHud.vue
Normal 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>
|
||||
42
src/components/SceneCanvas.vue
Normal file
42
src/components/SceneCanvas.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
141
src/composables/useSceneController.ts
Normal file
141
src/composables/useSceneController.ts
Normal 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
158
src/styles/world.css
Normal 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;
|
||||
}
|
||||
}
|
||||
50
tests/browser/scene-controls.spec.ts
Normal file
50
tests/browser/scene-controls.spec.ts
Normal 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%');
|
||||
});
|
||||
168
tests/unit/SceneCanvas.test.ts
Normal file
168
tests/unit/SceneCanvas.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user