feat: route-map navigation, persisted progress with a resume point, Alt+arrow exit keys and a trip log

This commit is contained in:
2026-10-10 18:57:03 +03:00
parent e2f2ed6b9b
commit e5c9f01818
5 changed files with 221 additions and 14 deletions

View File

@@ -18,6 +18,7 @@ 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';
@@ -60,6 +61,10 @@ const sectionLabel = (id: SectionId): string => {
/** 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];
@@ -75,6 +80,18 @@ const journeyBinding = useJourneySections(
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,
@@ -241,6 +258,7 @@ onUnmounted(() => removePageListeners());
<SiteNavigation
:items="canonicalNavigation"
:journey="journey"
:visited="route.visited.value"
:allow-motion="motion.policy.value.allowSceneRequest"
:on-section-link-click="navigation.onSectionLinkClick"
/>
@@ -249,9 +267,9 @@ onUnmounted(() => removePageListeners());
<div class="reading-stage">
<div class="reading-viewport">
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
<HeroJourney :identity="content.identity" :allow-motion="motion.policy.value.allowSceneRequest && pageLifecycle === 'active'" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
<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')" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<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">
@@ -261,15 +279,15 @@ onUnmounted(() => removePageListeners());
</dl>
</PortfolioSection>
<PortfolioSection id="experience" :exit="exitFor('experience')" :next-label="nextLabelFor('experience')" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
<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')" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
<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')" :layout="sectionLayouts['selected-builds']" eyebrow="Selected work" :heading="sectionLabel('selected-builds')">
<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]"
@@ -293,7 +311,7 @@ onUnmounted(() => removePageListeners());
</div>
</PortfolioSection>
<PortfolioSection id="additional-work" :exit="exitFor('additional-work')" :next-label="nextLabelFor('additional-work')" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
<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"
@@ -306,11 +324,11 @@ onUnmounted(() => removePageListeners());
</div>
</PortfolioSection>
<PortfolioSection id="toolkit" :exit="exitFor('toolkit')" :next-label="nextLabelFor('toolkit')" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
<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')" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
<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>