feat: wayfinding system across chapters, HUD and project plates; cut boot log, headlight and ops strip

This commit is contained in:
2026-10-10 17:46:17 +03:00
parent 8a77fc02b2
commit 629535ba04
16 changed files with 345 additions and 401 deletions

View File

@@ -2,12 +2,10 @@
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import ContactDestination from './ContactDestination.vue';
import DriveControls from './DriveControls.vue';
import BootSequence from './BootSequence.vue';
import ExperienceTimeline from './ExperienceTimeline.vue';
import HeroJourney from './HeroJourney.vue';
import InfrastructureDiagram from './InfrastructureDiagram.vue';
import JourneyHud from './JourneyHud.vue';
import OperationsStrip from './OperationsStrip.vue';
import PortfolioSection from './PortfolioSection.vue';
import ProjectCard from './ProjectCard.vue';
import ProjectCaseStudy from './ProjectCaseStudy.vue';
@@ -18,10 +16,10 @@ import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useAudioEngine } from '@/composables/useAudioEngine';
import { useAudioIntent } from '@/composables/useAudioIntent';
import { useJourneySections } from '@/composables/useJourneySections';
import { useHeadlight } from '@/composables/useHeadlight';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
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';
@@ -60,9 +58,16 @@ const sectionLabel = (id: SectionId): string => {
return item.label;
};
/** One wayfinding vocabulary: chapter plates reuse the roadside sign markers. */
const exitFor = (id: SectionId): string => signRouteFor(id).marker;
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();
useHeadlight(computed(() => motion.policy.value.allowSceneRequest));
const navigation = useKeyboardNavigation(canonicalSectionIds);
const journeyBinding = useJourneySections(
canonicalSectionIds,
@@ -139,6 +144,18 @@ watch(
(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) => {
@@ -215,11 +232,10 @@ onUnmounted(() => removePageListeners());
:sample-frame="sampleSceneFrame"
@state-change="updateSceneState"
/>
<BootSequence :allow-motion="motion.policy.value.allowSceneRequest" />
<OperationsStrip />
<JourneyHud
:journey="journey"
:section-label="activeSectionLabel"
:exit="exitFor(journey.activeSectionId)"
/>
<header class="site-header">
<SiteNavigation
@@ -235,7 +251,7 @@ onUnmounted(() => removePageListeners());
<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" />
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
<PortfolioSection id="profile" :exit="exitFor('profile')" :next-label="nextLabelFor('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">
@@ -245,19 +261,21 @@ onUnmounted(() => removePageListeners());
</dl>
</PortfolioSection>
<PortfolioSection id="experience" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
<PortfolioSection id="experience" :exit="exitFor('experience')" :next-label="nextLabelFor('experience')" :layout="sectionLayouts.experience" :heading="sectionLabel('experience')">
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection>
<PortfolioSection id="infrastructure" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
<PortfolioSection id="infrastructure" :exit="exitFor('infrastructure')" :next-label="nextLabelFor('infrastructure')" :layout="sectionLayouts.infrastructure" :heading="sectionLabel('infrastructure')">
<InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection>
<PortfolioSection id="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')" :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"
/>
@@ -267,28 +285,32 @@ onUnmounted(() => removePageListeners());
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" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
<PortfolioSection id="additional-work" :exit="exitFor('additional-work')" :next-label="nextLabelFor('additional-work')" :layout="sectionLayouts['additional-work']" :heading="sectionLabel('additional-work')">
<div class="compact-projects">
<ProjectCard
v-for="project in content.projects.additional"
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" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
<PortfolioSection id="toolkit" :exit="exitFor('toolkit')" :next-label="nextLabelFor('toolkit')" :layout="sectionLayouts.toolkit" :heading="sectionLabel('toolkit')">
<ToolkitGrid :groups="content.toolkit" />
</PortfolioSection>
<PortfolioSection id="contact" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
<PortfolioSection id="contact" :exit="exitFor('contact')" :next-label="nextLabelFor('contact')" :layout="sectionLayouts.contact" eyebrow="Destination" :heading="sectionLabel('contact')">
<footer>
<ContactDestination :contact="content.contact" />
</footer>

View File

@@ -1,64 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, shallowRef, watch } from 'vue';
const props = defineProps<{ allowMotion: boolean }>();
const SEEN_KEY = 'portfolio.boot.seen';
const LINES = [
'night-shift v1.0 bucharest 03:00',
'ingress ........ tls ok',
'identity ....... access boundaries enforced',
'nextcloud ...... migrated, rollback ready',
'backups ........ verified',
'all systems quiet. starting route.',
] as const;
const visible = shallowRef(false);
const shown = shallowRef(0);
const leaving = shallowRef(false);
const timers: number[] = [];
const finish = (): void => {
if (!visible.value || leaving.value) return;
leaving.value = true;
timers.push(window.setTimeout(() => { visible.value = false; }, 600));
};
const onKey = (): void => finish();
let started = false;
const start = (): void => {
if (started || !props.allowMotion || navigator.webdriver) return;
started = true;
let seen = false;
try { seen = window.sessionStorage.getItem(SEEN_KEY) === '1'; } catch { seen = false; }
if (seen) return;
try { window.sessionStorage.setItem(SEEN_KEY, '1'); } catch { /* optional */ }
visible.value = true;
LINES.forEach((_, index) => {
timers.push(window.setTimeout(() => { shown.value = index + 1; }, 260 + index * 280));
});
timers.push(window.setTimeout(finish, 260 + LINES.length * 280 + 500));
window.addEventListener('keydown', onKey, { once: true });
};
// The motion policy resolves in the parent's onMounted (after ours), so react to the prop.
onMounted(() => {
watch(() => props.allowMotion, start, { immediate: true });
});
onUnmounted(() => {
timers.forEach((id) => window.clearTimeout(id));
window.removeEventListener('keydown', onKey);
});
</script>
<template>
<div v-if="visible" class="boot-sequence" :data-leaving="leaving || undefined" aria-hidden="true" @click="finish">
<ol class="boot-sequence__log">
<li v-for="(line, index) in LINES.slice(0, shown)" :key="index">{{ line }}</li>
</ol>
<span class="boot-sequence__skip">click or press any key to skip</span>
</div>
</template>

View File

@@ -14,7 +14,7 @@ const emit = defineEmits<{
</script>
<template>
<section id="start" class="hero-journey" data-layout="hero" aria-labelledby="start-heading">
<section id="start" class="hero-journey" data-layout="hero" data-exit="01" aria-labelledby="start-heading">
<div class="hero-journey__content">
<h1 id="start-heading" class="hero-name" tabindex="-1"><span class="hero-name__text">{{ identity.name }}</span><NameParticles :allow-motion="allowMotion" :name="identity.name" /></h1>
<p class="hero-journey__role">{{ identity.role }}</p>

View File

@@ -1,17 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
import type { JourneySnapshot } from '@/journey/types';
import { bucharestClock } from '@/lib/operations';
const props = defineProps<{
journey: JourneySnapshot;
sectionLabel: string;
exit?: string | undefined;
}>();
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`);
// The dash clock is decorative and CSS-generated, so SSR and hydrated text stay identical.
const clock = shallowRef<string | undefined>(undefined);
let timer = 0;
onMounted(() => {
const tick = (): void => { clock.value = bucharestClock(new Date()); };
tick();
timer = window.setInterval(tick, 30_000);
});
onUnmounted(() => window.clearInterval(timer));
</script>
<template>
<aside class="journey-hud" aria-hidden="true">
<aside class="journey-hud" aria-hidden="true" :data-exit="exit" :data-clock="clock">
<span class="journey-hud__route"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__section">{{ sectionLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>

View File

@@ -1,34 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, shallowRef } from 'vue';
import { bucharestClock, uptimeSince } from '@/lib/operations';
/** Start of the current hosting-admin role (content file: Trupa lui Răzuș, Aug 2025). */
const SHIFT_START = new Date('2025-08-01T00:00:00Z');
const clock = shallowRef('--:--');
const shift = shallowRef('-- mo');
let timer = 0;
const tick = (): void => {
const now = new Date();
const { months, days } = uptimeSince(SHIFT_START, now);
clock.value = bucharestClock(now);
shift.value = `${months} mo ${days} d`;
};
onMounted(() => {
tick();
timer = window.setInterval(tick, 30_000);
});
onUnmounted(() => window.clearInterval(timer));
</script>
<template>
<!-- Purely decorative telemetry: all text is CSS-generated so the semantic document is unchanged. -->
<aside class="ops-strip" aria-hidden="true">
<span class="ops-strip__dot" />
<span class="ops-strip__item" data-label="Bucharest" :data-value="clock" />
<span class="ops-strip__sep" />
<span class="ops-strip__item" data-label="On shift" :data-value="shift" />
</aside>
</template>

View File

@@ -6,11 +6,15 @@ defineProps<{
layout: Exclude<SectionLayout, 'hero'>;
eyebrow?: string;
heading: string;
/** Roadside sign marker, rendered as a decorative CSS exit plate. */
exit?: string | undefined;
/** Next chapter label for the plate's "then" line (decorative, CSS-generated). */
nextLabel?: string | undefined;
}>();
</script>
<template>
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-layout="layout">
<section :id="id" class="portfolio-section" :aria-labelledby="`${id}-heading`" :data-layout="layout" :data-exit="exit" :data-next="nextLabel">
<div v-if="$slots.lead" class="project-stage__lead">
<p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>

View File

@@ -1,9 +1,16 @@
<script setup lang="ts">
import type { PublicProject } from '@/content/types';
import { routePlate } from '@/lib/route-plate';
import ProjectMediaGallery from './ProjectMediaGallery.vue';
withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo?: boolean }>(), {
withDefaults(defineProps<{
project: Readonly<PublicProject>;
allowAutomaticVideo?: boolean;
exit?: string | undefined;
index?: number;
}>(), {
allowAutomaticVideo: false,
index: 0,
});
</script>
@@ -13,6 +20,7 @@ withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo
:data-project-id="project.id"
:data-maturity="project.maturity"
:data-media-layout="project.mediaLayout ?? 'none'"
:data-plate="routePlate(exit, index, project)"
>
<h3>{{ project.title }}</h3>
<p>{{ project.summary }}</p>

View File

@@ -1,14 +1,18 @@
<script setup lang="ts">
import type { PublicProject } from '@/content/types';
import { routePlate } from '@/lib/route-plate';
import ProjectMediaGallery from './ProjectMediaGallery.vue';
withDefaults(defineProps<{
project: Readonly<PublicProject>;
priority?: 'primary' | 'supporting';
allowAutomaticVideo?: boolean;
exit?: string | undefined;
index?: number;
}>(), {
priority: 'supporting',
allowAutomaticVideo: false,
index: 0,
});
</script>
@@ -19,7 +23,7 @@ withDefaults(defineProps<{
:data-project-priority="priority"
:data-media-layout="project.mediaLayout ?? 'none'"
>
<header class="project-case-study__header">
<header class="project-case-study__header" :data-plate="routePlate(exit, index, project)">
<h3>{{ project.title }}</h3>
<p>{{ project.summary }}</p>
</header>