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 { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation'; import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences'; import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { assetUrl } from '@/content/asset-url'; import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes'; import { signRouteFor } from '@/journey/sign-routes';
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types'; 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. */ /** One wayfinding vocabulary: chapter plates reuse the roadside sign markers. */
const exitFor = (id: SectionId): string => signRouteFor(id).marker; 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 nextLabelFor = (id: SectionId): string | undefined => {
const index = canonicalSectionIds.indexOf(id); const index = canonicalSectionIds.indexOf(id);
const next = canonicalSectionIds[index + 1]; const next = canonicalSectionIds[index + 1];
@@ -75,6 +80,18 @@ const journeyBinding = useJourneySections(
computed(() => motion.policy.value.allowSceneRequest), computed(() => motion.policy.value.allowSceneRequest),
); );
const journey = journeyBinding.snapshot; 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({ const audioIntent = useAudioIntent({
canonicalIds: canonicalSectionIds, canonicalIds: canonicalSectionIds,
journey: journeyBinding, journey: journeyBinding,
@@ -241,6 +258,7 @@ onUnmounted(() => removePageListeners());
<SiteNavigation <SiteNavigation
:items="canonicalNavigation" :items="canonicalNavigation"
:journey="journey" :journey="journey"
:visited="route.visited.value"
:allow-motion="motion.policy.value.allowSceneRequest" :allow-motion="motion.policy.value.allowSceneRequest"
:on-section-link-click="navigation.onSectionLinkClick" :on-section-link-click="navigation.onSectionLinkClick"
/> />
@@ -249,9 +267,9 @@ onUnmounted(() => removePageListeners());
<div class="reading-stage"> <div class="reading-stage">
<div class="reading-viewport"> <div class="reading-viewport">
<main id="portfolio-content" class="portfolio-main" tabindex="-1"> <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> <p>{{ content.profile.introduction }}</p>
<dl class="profile-facts"> <dl class="profile-facts">
<template v-for="fact in content.profile.screeningFacts" :key="fact.label"> <template v-for="fact in content.profile.screeningFacts" :key="fact.label">
@@ -261,15 +279,15 @@ onUnmounted(() => removePageListeners());
</dl> </dl>
</PortfolioSection> </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" /> <ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection> </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" /> <InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection> </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> <template #lead>
<ProjectCaseStudy <ProjectCaseStudy
v-if="content.projects.selected[0]" v-if="content.projects.selected[0]"
@@ -293,7 +311,7 @@ onUnmounted(() => removePageListeners());
</div> </div>
</PortfolioSection> </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"> <div class="compact-projects">
<ProjectCard <ProjectCard
v-for="(project, index) in content.projects.additional" v-for="(project, index) in content.projects.additional"
@@ -306,11 +324,11 @@ onUnmounted(() => removePageListeners());
</div> </div>
</PortfolioSection> </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" /> <ToolkitGrid :groups="content.toolkit" />
</PortfolioSection> </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> <footer>
<ContactDestination :contact="content.contact" /> <ContactDestination :contact="content.contact" />
</footer> </footer>

View File

@@ -5,11 +5,14 @@ import NameParticles from './NameParticles.vue';
withDefaults(defineProps<{ withDefaults(defineProps<{
identity: Readonly<PortfolioContent['identity']>; identity: Readonly<PortfolioContent['identity']>;
allowMotion?: boolean; 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<{ const emit = defineEmits<{
explore: [event: MouseEvent]; explore: [event: MouseEvent];
contact: [event: MouseEvent]; contact: [event: MouseEvent];
resume: [event: MouseEvent];
}>(); }>();
</script> </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 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> <a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
</div> </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> </div>
<p class="print-portfolio-label" aria-hidden="true">Portfolio</p> <p class="print-portfolio-label" aria-hidden="true">Portfolio</p>
</section> </section>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts"> <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 { SectionId } from "@/content/types";
import type { JourneySnapshot } from "@/journey/types"; import type { JourneySnapshot } from "@/journey/types";
const props = defineProps<{ const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>; items: ReadonlyArray<{ id: SectionId; label: string }>;
journey: JourneySnapshot; journey: JourneySnapshot;
/** Exits passed in this browser (client-only memory). */
visited?: ReadonlySet<SectionId>;
allowMotion?: boolean; allowMotion?: boolean;
onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void; onSectionLinkClick: (event: MouseEvent, requestSign?: boolean) => void;
}>(); }>();
@@ -22,6 +24,35 @@ const navigationLabels: Readonly<Record<SectionId, string>> = {
}; };
const disclosure = ref<HTMLDetailsElement | null>(null); 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 enhanced = ref(false);
const docked = ref(false); const docked = ref(false);
const restScale = ref(1); const restScale = ref(1);
@@ -49,6 +80,7 @@ onMounted(() => {
measureRestWidth(); measureRestWidth();
enhanced.value = true; enhanced.value = true;
window.addEventListener('resize', measureRestWidth, { passive: true }); window.addEventListener('resize', measureRestWidth, { passive: true });
window.addEventListener('keydown', onKeydown);
compactQuery = window.matchMedia("(max-width: 767px)"); compactQuery = window.matchMedia("(max-width: 767px)");
syncDisclosureToViewport(compactQuery); syncDisclosureToViewport(compactQuery);
compactQuery.addEventListener("change", syncDisclosureToViewport); compactQuery.addEventListener("change", syncDisclosureToViewport);
@@ -56,6 +88,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', measureRestWidth); window.removeEventListener('resize', measureRestWidth);
window.removeEventListener('keydown', onKeydown);
compactQuery?.removeEventListener("change", syncDisclosureToViewport); compactQuery?.removeEventListener("change", syncDisclosureToViewport);
compactQuery = null; compactQuery = null;
}); });
@@ -68,11 +101,11 @@ const navigate = (event: MouseEvent): void => {
</script> </script>
<template> <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> <details ref="disclosure" class="site-navigation__disclosure" open>
<summary>Navigate</summary> <summary :data-exit="exitNumber(activeIndex)" :data-label="navigationLabels[items[activeIndex]?.id ?? 'start']">Navigate</summary>
<ul> <ul ref="list">
<li v-for="item in items" :key="item.id"> <li v-for="(item, index) in items" :key="item.id" :data-route="stateFor(index, item.id)">
<a <a
:href="`#${item.id}`" :href="`#${item.id}`"
:aria-current=" :aria-current="

View File

@@ -0,0 +1,77 @@
import { onMounted, ref, watch, type Ref } from 'vue';
import type { SectionId } from '@/content/types';
/** Remembers which exits a visitor has passed, and where they left the road,
* in this browser only. Nothing is read before mount, so the server render and
* the first client render stay identical; storage failures (private windows,
* blocked site data) simply leave the memory empty. */
const STORAGE_KEY = 'nightdrive:route';
interface StoredRoute {
visited: SectionId[];
last: SectionId | null;
}
const read = (ids: readonly SectionId[]): StoredRoute => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) return { visited: [], last: null };
const parsed = JSON.parse(raw) as Partial<StoredRoute>;
const known = (id: unknown): id is SectionId => ids.includes(id as SectionId);
return {
visited: Array.isArray(parsed.visited) ? parsed.visited.filter(known) : [],
last: known(parsed.last) ? parsed.last : null,
};
} catch {
return { visited: [], last: null };
}
};
const write = (route: StoredRoute): void => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(route));
} catch {
// Memory is a convenience; the journey works without it.
}
};
export interface RouteMemory {
/** Exits passed in this or an earlier visit (empty until mounted). */
visited: Ref<ReadonlySet<SectionId>>;
/** Where the previous visit stopped, offered once as a resume point. */
resumeFrom: Ref<SectionId | null>;
/** Minutes since this page load, sampled when the destination is reached. */
tripMinutes: Ref<number | null>;
}
export function useRouteMemory(
ids: readonly SectionId[],
active: () => SectionId,
): RouteMemory {
const visited = ref<ReadonlySet<SectionId>>(new Set());
const resumeFrom = ref<SectionId | null>(null);
const tripMinutes = ref<number | null>(null);
onMounted(() => {
const startedAt = performance.now();
const stored = read(ids);
visited.value = new Set(stored.visited);
const landedAtTop = window.location.hash === '' && window.scrollY < 8;
if (landedAtTop && stored.last !== null && stored.last !== ids[0]) {
resumeFrom.value = stored.last;
}
watch(active, (id) => {
if (id !== ids[0]) resumeFrom.value = null;
if (id === ids[ids.length - 1] && tripMinutes.value === null) {
tripMinutes.value = Math.max(1, Math.round((performance.now() - startedAt) / 60_000));
}
if (!visited.value.has(id)) visited.value = new Set([...visited.value, id]);
// Idling at the on-ramp keeps the offered resume point for next time.
const last = id === ids[0] && resumeFrom.value !== null ? resumeFrom.value : id;
write({ visited: [...visited.value], last });
}, { immediate: true });
});
return { visited, resumeFrom, tripMinutes };
}

View File

@@ -0,0 +1,65 @@
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { defineComponent, h, nextTick, ref } from "vue";
import type { SectionId } from "@/content/types";
import { useRouteMemory, type RouteMemory } from "@/composables/useRouteMemory";
const ids = ["start", "profile", "experience", "contact"] as unknown as readonly SectionId[];
const harness = (initial: SectionId) => {
const active = ref<SectionId>(initial);
let memory: RouteMemory | undefined;
const wrapper = mount(defineComponent({
setup() {
memory = useRouteMemory(ids, () => active.value);
return () => h("div");
},
}));
return { active, memory: memory!, wrapper };
};
afterEach(() => {
window.localStorage.clear();
window.location.hash = "";
});
describe("route memory", () => {
it("records passed exits and offers the last stop as a resume point", async () => {
const first = harness("start" as SectionId);
first.active.value = "profile" as SectionId;
await nextTick();
first.active.value = "experience" as SectionId;
await nextTick();
expect([...first.memory.visited.value]).toEqual(["start", "profile", "experience"]);
first.wrapper.unmount();
const second = harness("start" as SectionId);
await nextTick();
expect(second.memory.resumeFrom.value).toBe("experience");
expect(second.memory.visited.value.has("profile" as SectionId)).toBe(true);
// Idling at the on-ramp keeps the resume point for the next visit.
second.wrapper.unmount();
expect(harness("start" as SectionId).memory.resumeFrom.value).toBe("experience");
});
it("drops the offer once the visitor drives on and ignores corrupt storage", async () => {
window.localStorage.setItem("nightdrive:route", JSON.stringify({ visited: ["profile", "nowhere"], last: "profile" }));
const run = harness("start" as SectionId);
expect(run.memory.resumeFrom.value).toBe("profile");
expect([...run.memory.visited.value]).toEqual(["profile", "start"]);
run.active.value = "experience" as SectionId;
await nextTick();
expect(run.memory.resumeFrom.value).toBeNull();
window.localStorage.setItem("nightdrive:route", "{not json");
expect(harness("start" as SectionId).memory.resumeFrom.value).toBeNull();
});
it("logs the trip once the destination is reached", async () => {
const run = harness("start" as SectionId);
expect(run.memory.tripMinutes.value).toBeNull();
run.active.value = "contact" as SectionId;
await nextTick();
expect(run.memory.tripMinutes.value).toBe(1);
});
});