From e5c9f018186dc7d4cf7912dec4601e983508b9dd Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 18:57:03 +0300 Subject: [PATCH] feat: route-map navigation, persisted progress with a resume point, Alt+arrow exit keys and a trip log --- src/components/AppShell.vue | 34 ++++++++++---- src/components/HeroJourney.vue | 16 ++++++- src/components/SiteNavigation.vue | 43 +++++++++++++++-- src/composables/useRouteMemory.ts | 77 +++++++++++++++++++++++++++++++ tests/unit/route-memory.test.ts | 65 ++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 src/composables/useRouteMemory.ts create mode 100644 tests/unit/route-memory.test.ts diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index 7496192..004fdab 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -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()); @@ -249,9 +267,9 @@ onUnmounted(() => removePageListeners());
- + - +

{{ content.profile.introduction }}