feat: route-map navigation, persisted progress with a resume point, Alt+arrow exit keys and a trip log
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -5,11 +5,14 @@ import NameParticles from './NameParticles.vue';
|
||||
withDefaults(defineProps<{
|
||||
identity: Readonly<PortfolioContent['identity']>;
|
||||
allowMotion?: boolean;
|
||||
}>(), { allowMotion: false });
|
||||
/** Where a returning visitor left the road (client-only, after mount). */
|
||||
resume?: { id: string; exit: string; label: string } | null;
|
||||
}>(), { allowMotion: false, resume: null });
|
||||
|
||||
const emit = defineEmits<{
|
||||
explore: [event: MouseEvent];
|
||||
contact: [event: MouseEvent];
|
||||
resume: [event: MouseEvent];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -24,6 +27,17 @@ const emit = defineEmits<{
|
||||
<a class="hero-journey__action hero-journey__action--primary" href="#contact" @click="emit('contact', $event)">Contact Me</a>
|
||||
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
|
||||
</div>
|
||||
<!-- The visible label is CSS generated content, so the hydrated text stays
|
||||
identical to the server document; the accessible name is the aria-label. -->
|
||||
<a
|
||||
v-if="resume"
|
||||
class="hero-journey__resume"
|
||||
:href="`#${resume.id}`"
|
||||
:data-exit="resume.exit"
|
||||
:data-label="resume.label"
|
||||
:aria-label="`Resume the drive at ${resume.label}, exit ${resume.exit}`"
|
||||
@click="emit('resume', $event)"
|
||||
></a>
|
||||
</div>
|
||||
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { SectionId } from "@/content/types";
|
||||
import type { JourneySnapshot } from "@/journey/types";
|
||||
|
||||
const props = defineProps<{
|
||||
items: ReadonlyArray<{ id: SectionId; label: string }>;
|
||||
journey: JourneySnapshot;
|
||||
/** Exits passed in this browser (client-only memory). */
|
||||
visited?: ReadonlySet<SectionId>;
|
||||
allowMotion?: boolean;
|
||||
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
|
||||
}>();
|
||||
@@ -22,6 +24,35 @@ const navigationLabels: Readonly<Record<SectionId, string>> = {
|
||||
};
|
||||
|
||||
const disclosure = ref<HTMLDetailsElement | null>(null);
|
||||
const list = ref<HTMLUListElement | null>(null);
|
||||
|
||||
// The route map: the road fills up to the current exit. It changes only on
|
||||
// chapter change, never per scroll sample.
|
||||
const activeIndex = computed(() =>
|
||||
Math.max(0, props.items.findIndex((item) => item.id === props.journey.activeSectionId)),
|
||||
);
|
||||
const exitNumber = (index: number): string => String(index + 1).padStart(2, "0");
|
||||
const stateFor = (index: number, id: SectionId): string =>
|
||||
index === activeIndex.value
|
||||
? "here"
|
||||
: index < activeIndex.value || props.visited?.has(id) === true
|
||||
? "passed"
|
||||
: "ahead";
|
||||
|
||||
/** Alt+ArrowDown / Alt+ArrowUp drive to the next or previous exit. Modified
|
||||
* arrow keys are not character keys, so they cannot fire by accident while
|
||||
* typing or with speech input (WCAG 2.1.4), and editable fields are left alone. */
|
||||
const onKeydown = (event: KeyboardEvent): void => {
|
||||
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return;
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
const next = Math.min(props.items.length - 1, Math.max(0, activeIndex.value + step));
|
||||
if (next === activeIndex.value) return;
|
||||
event.preventDefault();
|
||||
list.value?.querySelectorAll<HTMLAnchorElement>("a")[next]?.click();
|
||||
};
|
||||
const enhanced = ref(false);
|
||||
const docked = ref(false);
|
||||
const restScale = ref(1);
|
||||
@@ -49,6 +80,7 @@ onMounted(() => {
|
||||
measureRestWidth();
|
||||
enhanced.value = true;
|
||||
window.addEventListener('resize', measureRestWidth, { passive: true });
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
compactQuery = window.matchMedia("(max-width: 767px)");
|
||||
syncDisclosureToViewport(compactQuery);
|
||||
compactQuery.addEventListener("change", syncDisclosureToViewport);
|
||||
@@ -56,6 +88,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', measureRestWidth);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
compactQuery?.removeEventListener("change", syncDisclosureToViewport);
|
||||
compactQuery = null;
|
||||
});
|
||||
@@ -68,11 +101,11 @@ const navigate = (event: MouseEvent): void => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale }">
|
||||
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length }">
|
||||
<details ref="disclosure" class="site-navigation__disclosure" open>
|
||||
<summary>Navigate</summary>
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
|
||||
<ul ref="list">
|
||||
<li v-for="(item, index) in items" :key="item.id" :data-route="stateFor(index, item.id)">
|
||||
<a
|
||||
:href="`#${item.id}`"
|
||||
:aria-current="
|
||||
|
||||
Reference in New Issue
Block a user