Files
Portfolio/src/components/AppShell.vue
LucaP 0ae27168cd feat: overhead gantry top bar with a docking sign window
The top bar becomes a steel gantry. Its centre window is a mask cut-out the
3D exit sign docks into; between signs the window shows the dash read-out.
The drive controls move into the right bay, and the reading viewport is
clipped at the beam, so the empty band under the bar is gone.
2026-10-10 22:20:48 +03:00

359 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import ContactDestination from './ContactDestination.vue';
import DriveControls from './DriveControls.vue';
import ExperienceTimeline from './ExperienceTimeline.vue';
import HeroJourney from './HeroJourney.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 { useAudioEngine } from '@/composables/useAudioEngine';
import { useAudioIntent } from '@/composables/useAudioIntent';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy } from '@/journey/motion-policy';
import type { AudioSourceVariant, SoundtrackSources } from '@/audio/types';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
const props = defineProps<{
content: Readonly<PortfolioContent>;
manifest: Readonly<PublicAssetManifest>;
}>();
const canonicalSectionIds = [
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
] as const satisfies readonly SectionId[];
const sectionLayouts = {
start: 'hero',
profile: 'right-rail',
experience: 'left-timeline',
infrastructure: 'full-diagram',
'selected-builds': 'project-stage',
'additional-work': 'compact-rail',
toolkit: 'toolkit-field',
contact: 'destination',
} as const satisfies Readonly<Record<SectionId, SectionLayout>>;
const navigationById = computed(() => new Map(props.content.navigation.map((item) => [item.id, item])));
const sectionLabel = (id: SectionId): string => {
const item = navigationById.value.get(id);
if (item === undefined) {
throw new Error(`Missing canonical navigation label: ${id}`);
}
return item.label;
};
/** One wayfinding vocabulary: chapter plates reuse the roadside sign markers. */
const exitFor = (id: SectionId): string => signRouteFor(id).marker;
const nextExitFor = (id: SectionId): string | undefined => {
const next = canonicalSectionIds[canonicalSectionIds.indexOf(id) + 1];
return next === undefined ? undefined : exitFor(next);
};
const nextLabelFor = (id: SectionId): string | undefined => {
const index = canonicalSectionIds.indexOf(id);
const next = canonicalSectionIds[index + 1];
return next === undefined ? undefined : sectionLabel(next);
};
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
const motion = useMotionPreferences();
const navigation = useKeyboardNavigation(canonicalSectionIds);
const journeyBinding = useJourneySections(
canonicalSectionIds,
navigation.state,
computed(() => motion.policy.value.allowSceneRequest),
);
const journey = journeyBinding.snapshot;
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
const resume = computed(() => {
const id = route.resumeFrom.value;
return id === null ? null : { id, exit: exitFor(id), label: sectionLabel(id) };
});
/** Decorative trip log for the destination (CSS generated content). */
const tripLog = computed(() => {
const minutes = route.tripMinutes.value;
if (minutes === null) return undefined;
const exits = route.visited.value.size;
return `${exits} OF ${canonicalSectionIds.length} EXITS \u00b7 ${minutes} MIN ON THE ROAD`;
});
const audioIntent = useAudioIntent({
canonicalIds: canonicalSectionIds,
journey: journeyBinding,
navigation: navigation.state,
});
const variants = (
sources: { readonly opusAssetId: string; readonly mp3AssetId: string },
): readonly AudioSourceVariant[] => [
{ url: assetUrl(props.manifest, sources.opusAssetId), mediaType: 'audio/ogg' },
{ url: assetUrl(props.manifest, sources.mp3AssetId), mediaType: 'audio/mpeg' },
];
const soundtrackSources = computed<SoundtrackSources | null>(() => {
const soundtrack = props.content.soundtrack;
if (soundtrack === undefined) return null;
return {
intro: variants(soundtrack.intro),
main: variants(soundtrack.main),
afterglow: variants(soundtrack.afterglow),
driveLift: variants(soundtrack.driveLift),
};
});
const audio = useAudioEngine({
chapter: audioIntent.chapter,
drive: audioIntent.drive,
soundtrack: soundtrackSources,
volume: motion.volume,
setVolume: motion.setVolume,
});
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 requestedPerformanceTier = TEST_BUILD
? new URL(window.location.href).searchParams.get('__portfolio_tier')
: null;
const pinnedPerformanceTier = requestedPerformanceTier === 'high' || requestedPerformanceTier === 'low'
? requestedPerformanceTier
: null;
const actualQuality = computed(() => pinnedPerformanceTier ?? 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 displayedSceneState = computed(() => controlsReady.value ? sceneState.value : 'running');
let removePageListeners = (): void => {};
const sampleSceneFrame = (start: number, end: number): void => {
if (pinnedPerformanceTier === null) adaptive.sampleFrame(start, end);
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneFrame(start, end);
};
const updateSceneState = (state: SceneHostState): void => {
sceneState.value = state;
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneState(state);
};
watch(
() => journey.value.activeSectionId,
(id) => navigation.notePassiveSection(id),
);
// Light the current chapter's plate once per arrival (attribute only, no per-frame work).
watch(
() => [journey.value.activeSectionId, controlsReady.value] as const,
([id, ready]) => {
if (!ready) return;
for (const section of document.querySelectorAll<HTMLElement>('#portfolio-content > section[id]')) {
section.toggleAttribute('data-current', section.id === id);
}
},
{ flush: 'post' },
);
watch(
() => motion.policy.value.maximumTier,
(tier, previous) => {
if (tier !== previous) {
adaptive.setMaximum(tier, controlsReady.value ? 'resize' : 'manual');
}
},
{ flush: 'sync' },
);
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
motion.setMaximumQuality(tier);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const enableMotion = (): void => {
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
motion.setMaximumQuality(tier);
motion.setPaused(false);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const resumeMotion = (): void => {
motion.setPaused(false);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('resume');
};
const pauseMotion = (): void => {
motion.setPaused(true);
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('pause');
};
const startDrive = (): void => {
audio.startFromGesture();
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('start');
};
const toggleAudioMuted = (): void => {
const action = audio.muted.value ? 'unmute' : 'mute';
audio.toggleMuted();
if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(action);
};
const updateAudioVolume = (value: number): void => {
audio.setVolume(value);
if (TEST_BUILD && value === 0.35) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('volume');
};
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"
:sample-frame="sampleSceneFrame"
@state-change="updateSceneState"
/>
<JourneyHud
:journey="journey"
:section-label="activeSectionLabel"
:exit="exitFor(journey.activeSectionId)"
:next-label="nextLabelFor(journey.activeSectionId)"
:next-exit="nextExitFor(journey.activeSectionId)"
/>
<header class="site-header">
<SiteNavigation
:items="canonicalNavigation"
:journey="journey"
:visited="route.visited.value"
:allow-motion="motion.policy.value.allowSceneRequest"
:on-section-link-click="navigation.onSectionLinkClick"
/>
</header>
<div class="reading-stage">
<div class="reading-viewport">
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
<HeroJourney :identity="content.identity" :resume="resume" @resume="navigation.onSectionLinkClick" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
<PortfolioSection id="profile" :exit="exitFor('profile')" :next-label="nextLabelFor('profile')" :next-exit="nextExitFor('profile')" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<p>{{ content.profile.introduction }}</p>
<dl class="profile-facts">
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
<dt>{{ fact.label }}</dt>
<dd>{{ fact.value }}</dd>
</template>
</dl>
</PortfolioSection>
<PortfolioSection id="experience" :exit="exitFor('experience')" :next-label="nextLabelFor('experience')" :next-exit="nextExitFor('experience')" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection>
<PortfolioSection id="infrastructure" :exit="exitFor('infrastructure')" :next-label="nextLabelFor('infrastructure')" :next-exit="nextExitFor('infrastructure')" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
<InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection>
<PortfolioSection id="selected-builds" :exit="exitFor('selected-builds')" :next-label="nextLabelFor('selected-builds')" :next-exit="nextExitFor('selected-builds')" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
<template #lead>
<ProjectCaseStudy
v-if="content.projects.selected[0]"
:project="content.projects.selected[0]"
:exit="exitFor('selected-builds')"
:index="0"
priority="primary"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</template>
<div class="project-stage__projects">
<ProjectCaseStudy
v-for="(project, index) in content.projects.selected.slice(1)"
:key="project.id"
:project="project"
:exit="exitFor('selected-builds')"
:index="index + 1"
:priority="index === 0 ? 'primary' : 'supporting'"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
</PortfolioSection>
<PortfolioSection id="additional-work" :exit="exitFor('additional-work')" :next-label="nextLabelFor('additional-work')" :next-exit="nextExitFor('additional-work')" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
<div class="compact-projects">
<ProjectCard
v-for="(project, index) in content.projects.additional"
:key="project.id"
:project="project"
:exit="exitFor('additional-work')"
:index="index"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/>
</div>
</PortfolioSection>
<PortfolioSection id="toolkit" :exit="exitFor('toolkit')" :next-label="nextLabelFor('toolkit')" :next-exit="nextExitFor('toolkit')" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
<ToolkitGrid :groups="content.toolkit" />
</PortfolioSection>
<PortfolioSection id="contact" :exit="exitFor('contact')" :next-label="nextLabelFor('contact')" :next-exit="nextExitFor('contact')" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')" :data-trip="tripLog">
<footer>
<ContactDestination :contact="content.contact" />
</footer>
</PortfolioSection>
</main>
</div>
</div>
<span class="content-focus-cue" aria-hidden="true">Portfolio content</span>
<DriveControls
:policy="displayedPolicy"
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
:scene-status="displayedSceneState"
:audio-state="audio.state.value"
:audio-muted="audio.muted.value"
:audio-volume="controlsReady ? motion.volume.value : 0.4"
@pause="pauseMotion"
@resume="resumeMotion"
@enable="enableMotion"
@quality-change="setQuality"
@audio-start="startDrive"
@audio-resume="audio.resumeFromGesture"
@audio-mute-toggle="toggleAudioMuted"
@audio-volume-change="updateAudioVolume"
/>
</template>