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

@@ -10,7 +10,7 @@ import './styles/evolution.css';
import './styles/chromatic.css'; import './styles/chromatic.css';
import './styles/refinements.css'; import './styles/refinements.css';
import './styles/title-navigation.css'; import './styles/title-navigation.css';
import './styles/nightshift.css'; import './styles/wayfinding.css';
import './styles/print.css'; import './styles/print.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>(); defineProps<{ inputs: Readonly<ProductionInputs> }>();

View File

@@ -2,12 +2,10 @@
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'; import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import ContactDestination from './ContactDestination.vue'; import ContactDestination from './ContactDestination.vue';
import DriveControls from './DriveControls.vue'; import DriveControls from './DriveControls.vue';
import BootSequence from './BootSequence.vue';
import ExperienceTimeline from './ExperienceTimeline.vue'; import ExperienceTimeline from './ExperienceTimeline.vue';
import HeroJourney from './HeroJourney.vue'; import HeroJourney from './HeroJourney.vue';
import InfrastructureDiagram from './InfrastructureDiagram.vue'; import InfrastructureDiagram from './InfrastructureDiagram.vue';
import JourneyHud from './JourneyHud.vue'; import JourneyHud from './JourneyHud.vue';
import OperationsStrip from './OperationsStrip.vue';
import PortfolioSection from './PortfolioSection.vue'; import PortfolioSection from './PortfolioSection.vue';
import ProjectCard from './ProjectCard.vue'; import ProjectCard from './ProjectCard.vue';
import ProjectCaseStudy from './ProjectCaseStudy.vue'; import ProjectCaseStudy from './ProjectCaseStudy.vue';
@@ -18,10 +16,10 @@ import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
import { useAudioEngine } from '@/composables/useAudioEngine'; import { useAudioEngine } from '@/composables/useAudioEngine';
import { useAudioIntent } from '@/composables/useAudioIntent'; import { useAudioIntent } from '@/composables/useAudioIntent';
import { useJourneySections } from '@/composables/useJourneySections'; import { useJourneySections } from '@/composables/useJourneySections';
import { useHeadlight } from '@/composables/useHeadlight';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation'; import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMotionPreferences } from '@/composables/useMotionPreferences'; import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { assetUrl } from '@/content/asset-url'; import { assetUrl } from '@/content/asset-url';
import { signRouteFor } from '@/journey/sign-routes';
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types'; import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
import type { SceneHostState } from '@/composables/useSceneController'; import type { SceneHostState } from '@/composables/useSceneController';
import type { MotionPolicy } from '@/journey/motion-policy'; import type { MotionPolicy } from '@/journey/motion-policy';
@@ -60,9 +58,16 @@ const sectionLabel = (id: SectionId): string => {
return item.label; 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 canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
const motion = useMotionPreferences(); const motion = useMotionPreferences();
useHeadlight(computed(() => motion.policy.value.allowSceneRequest));
const navigation = useKeyboardNavigation(canonicalSectionIds); const navigation = useKeyboardNavigation(canonicalSectionIds);
const journeyBinding = useJourneySections( const journeyBinding = useJourneySections(
canonicalSectionIds, canonicalSectionIds,
@@ -139,6 +144,18 @@ watch(
(id) => navigation.notePassiveSection(id), (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( watch(
() => motion.policy.value.maximumTier, () => motion.policy.value.maximumTier,
(tier, previous) => { (tier, previous) => {
@@ -215,11 +232,10 @@ onUnmounted(() => removePageListeners());
:sample-frame="sampleSceneFrame" :sample-frame="sampleSceneFrame"
@state-change="updateSceneState" @state-change="updateSceneState"
/> />
<BootSequence :allow-motion="motion.policy.value.allowSceneRequest" />
<OperationsStrip />
<JourneyHud <JourneyHud
:journey="journey" :journey="journey"
:section-label="activeSectionLabel" :section-label="activeSectionLabel"
:exit="exitFor(journey.activeSectionId)"
/> />
<header class="site-header"> <header class="site-header">
<SiteNavigation <SiteNavigation
@@ -235,7 +251,7 @@ onUnmounted(() => removePageListeners());
<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" :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> <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">
@@ -245,19 +261,21 @@ onUnmounted(() => removePageListeners());
</dl> </dl>
</PortfolioSection> </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" /> <ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
</PortfolioSection> </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" /> <InfrastructureDiagram :infrastructure="content.infrastructure" />
</PortfolioSection> </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> <template #lead>
<ProjectCaseStudy <ProjectCaseStudy
v-if="content.projects.selected[0]" v-if="content.projects.selected[0]"
:project="content.projects.selected[0]" :project="content.projects.selected[0]"
:exit="exitFor('selected-builds')"
:index="0"
priority="primary" priority="primary"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo" :allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/> />
@@ -267,28 +285,32 @@ onUnmounted(() => removePageListeners());
v-for="(project, index) in content.projects.selected.slice(1)" v-for="(project, index) in content.projects.selected.slice(1)"
:key="project.id" :key="project.id"
:project="project" :project="project"
:exit="exitFor('selected-builds')"
:index="index + 1"
:priority="index === 0 ? 'primary' : 'supporting'" :priority="index === 0 ? 'primary' : 'supporting'"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo" :allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/> />
</div> </div>
</PortfolioSection> </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"> <div class="compact-projects">
<ProjectCard <ProjectCard
v-for="project in content.projects.additional" v-for="(project, index) in content.projects.additional"
:key="project.id" :key="project.id"
:project="project" :project="project"
:exit="exitFor('additional-work')"
:index="index"
:allow-automatic-video="motion.policy.value.allowAutomaticVideo" :allow-automatic-video="motion.policy.value.allowAutomaticVideo"
/> />
</div> </div>
</PortfolioSection> </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" /> <ToolkitGrid :groups="content.toolkit" />
</PortfolioSection> </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> <footer>
<ContactDestination :contact="content.contact" /> <ContactDestination :contact="content.contact" />
</footer> </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> </script>
<template> <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"> <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> <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> <p class="hero-journey__role">{{ identity.role }}</p>

View File

@@ -1,17 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onMounted, onUnmounted, shallowRef } from 'vue';
import type { JourneySnapshot } from '@/journey/types'; import type { JourneySnapshot } from '@/journey/types';
import { bucharestClock } from '@/lib/operations';
const props = defineProps<{ const props = defineProps<{
journey: JourneySnapshot; journey: JourneySnapshot;
sectionLabel: string; sectionLabel: string;
exit?: string | undefined;
}>(); }>();
const routePercent = computed(() => `${Math.round(Math.min(1, Math.max(0, props.journey.routeProgress)) * 100)}%`); 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> </script>
<template> <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__route"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__section">{{ sectionLabel }}</span> <span class="journey-hud__section">{{ sectionLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong> <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'>; layout: Exclude<SectionLayout, 'hero'>;
eyebrow?: string; eyebrow?: string;
heading: 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> </script>
<template> <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"> <div v-if="$slots.lead" class="project-stage__lead">
<p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p> <p v-if="eyebrow" class="section-eyebrow">{{ eyebrow }}</p>
<h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2> <h2 :id="`${id}-heading`" tabindex="-1">{{ heading }}</h2>

View File

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

View File

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

View File

@@ -1,56 +0,0 @@
import { onMounted, onUnmounted, watch, type Ref } from 'vue';
const SURFACES = '.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure';
/**
* Pointer light for fine pointers only. Writes `--hx/--hy` (px) on the root for the
* headlight beam and `--mx/--my` (%) on the hovered surface for its spotlight.
*/
export function useHeadlight(enabled: Ref<boolean>): void {
let frame = 0;
let pending: PointerEvent | undefined;
let active = false;
const flush = (): void => {
frame = 0;
const event = pending;
pending = undefined;
if (event === undefined) return;
const root = document.documentElement;
root.style.setProperty('--hx', `${event.clientX}px`);
root.style.setProperty('--hy', `${event.clientY}px`);
const surface = (event.target as Element | null)?.closest<HTMLElement>(SURFACES);
if (surface !== null && surface !== undefined) {
const box = surface.getBoundingClientRect();
surface.style.setProperty('--mx', `${((event.clientX - box.left) / box.width) * 100}%`);
surface.style.setProperty('--my', `${((event.clientY - box.top) / box.height) * 100}%`);
}
};
const onMove = (event: PointerEvent): void => {
if (!enabled.value || event.pointerType === 'touch') return;
if (!active) {
active = true;
document.documentElement.dataset.headlight = 'on';
}
pending = event;
if (frame === 0) frame = requestAnimationFrame(flush);
};
watch(enabled, (value) => {
if (value) return;
active = false;
delete document.documentElement.dataset.headlight;
});
onMounted(() => {
if (!window.matchMedia('(pointer: fine)').matches) return;
window.addEventListener('pointermove', onMove, { passive: true });
});
onUnmounted(() => {
window.removeEventListener('pointermove', onMove);
if (frame !== 0) cancelAnimationFrame(frame);
delete document.documentElement.dataset.headlight;
});
}

View File

@@ -1,31 +1,3 @@
export interface Uptime {
readonly months: number;
readonly days: number;
}
/** Whole calendar months plus remaining days since `start`, clamped to zero. */
export function uptimeSince(start: Date, now: Date): Uptime {
if (now.getTime() <= start.getTime()) return { months: 0, days: 0 };
let months = (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + (now.getUTCMonth() - start.getUTCMonth());
let anchor = addMonths(start, months);
if (anchor.getTime() > now.getTime()) {
months -= 1;
anchor = addMonths(start, months);
}
const days = Math.floor((now.getTime() - anchor.getTime()) / 86_400_000);
return { months, days };
}
function addMonths(date: Date, months: number): Date {
return new Date(Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth() + months,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
));
}
const clockFormat = new Intl.DateTimeFormat('en-GB', { const clockFormat = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@@ -33,6 +5,7 @@ const clockFormat = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Bucharest', timeZone: 'Europe/Bucharest',
}); });
/** Local Bucharest time as HH:MM (24h), for the decorative dash clock. */
export function bucharestClock(now: Date): string { export function bucharestClock(now: Date): string {
return clockFormat.format(now); return clockFormat.format(now);
} }

15
src/lib/route-plate.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { PublicProject } from '@/content/types';
/**
* Decorative registration plate for a project, e.g. "05.1 / PRIVATE / ACTIVE".
* Built only from real content fields; rendered through CSS generated content.
*/
export function routePlate(
exit: string | undefined,
index: number,
project: Pick<PublicProject, 'visibility' | 'maturity'>,
): string {
const code = exit === undefined ? `${index + 1}` : `${exit}.${index + 1}`;
const maturity = project.maturity.replace(/-/gu, ' ');
return `${code} / ${project.visibility} / ${maturity}`.toUpperCase();
}

View File

@@ -1,184 +0,0 @@
/* Night Shift: cinematic layer on top of the night-drive world. Imported last. */
:root {
--ns-grain: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='.55'/></svg>");
}
/* Film stock: grain + vignette above the world, below the UI. */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: var(--z-atmosphere);
pointer-events: none;
background:
radial-gradient(120% 90% at 50% 45%, transparent 55%, rgb(2 4 12 / 55%) 100%),
var(--ns-grain);
background-size: auto, 160px 160px;
mix-blend-mode: soft-light;
opacity: 0.5;
}
/* Operations strip */
.ops-strip {
position: fixed;
inset-block-end: 22px;
inset-inline: 0;
margin-inline: auto;
width: max-content;
max-width: calc(100vw - 32px);
z-index: var(--z-hud);
display: none;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 1rem;
border: var(--panel-border);
border-radius: 999px;
background: var(--surface);
color: var(--text-muted);
font: 500 0.72rem/1 var(--mono);
letter-spacing: 0.08em;
text-transform: uppercase;
pointer-events: none;
}
.ops-strip__item::before { content: attr(data-label) ' '; }
.ops-strip__item::after { content: attr(data-value); color: var(--text); font-weight: 600; }
.ops-strip__sep { opacity: 0.4; }
.ops-strip__sep::before { content: '/'; }
.ops-strip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #6ee7a8;
box-shadow: 0 0 10px #6ee7a8;
}
@media (min-width: 1100px) {
.ops-strip { display: inline-flex; }
}
@media (prefers-reduced-motion: no-preference) {
.ops-strip__dot { animation: ns-pulse 2.4s ease-in-out infinite; }
}
@keyframes ns-pulse { 50% { opacity: 0.35; } }
/* Boot sequence */
.boot-sequence {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
align-content: center;
justify-items: start;
gap: 2rem;
padding: clamp(1.5rem, 8vw, 8rem);
background: #02040c;
color: var(--accent);
font: 500 clamp(0.8rem, 0.7rem + 0.5vw, 1.05rem)/1.8 var(--mono);
cursor: pointer;
transition: opacity 600ms ease, filter 600ms ease;
}
.boot-sequence[data-leaving] { opacity: 0; filter: blur(6px); pointer-events: none; }
.boot-sequence__log { list-style: none; margin: 0; padding: 0; }
.boot-sequence__log li::before { content: '> '; color: var(--world-magenta); }
.boot-sequence__log li:last-child::after {
content: '_';
margin-inline-start: 0.2em;
animation: ns-blink 700ms steps(1) infinite;
}
.boot-sequence__skip { color: var(--text-muted); font-size: 0.75rem; letter-spacing: 0.1em; text-transform: uppercase; }
@keyframes ns-blink { 50% { opacity: 0; } }
/* Headlight: soft beam that follows a fine pointer. */
html[data-headlight='on'] .reading-viewport::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: radial-gradient(420px circle at var(--hx, 50%) var(--hy, 50%), rgb(255 214 147 / 10%), transparent 70%);
mix-blend-mode: screen;
}
/* Spotlight borders on interactive surfaces. */
.project-case-study,
.project-card,
.contact-destination,
.infrastructure-flow-figure {
position: relative;
isolation: isolate;
}
html[data-headlight='on'] :is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure)::after {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(320px circle at var(--mx, 50%) var(--my, 50%), rgb(120 216 229 / 14%), transparent 65%);
opacity: 0;
transition: opacity 240ms ease;
}
html[data-headlight='on'] :is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure):hover::after {
opacity: 1;
}
/* Chapter numerals: outlined, drifting section numbers. */
.reading-stage { counter-reset: chapter; }
.portfolio-section > h2,
.project-stage__lead > h2 { position: relative; }
.portfolio-section { counter-increment: chapter; }
.portfolio-section > h2::before,
.project-stage__lead > h2::before {
content: '0' counter(chapter) / '';
position: absolute;
inset-block-start: 50%;
inset-inline-end: 0;
translate: 0 -50%;
font: 700 var(--step-4)/0.85 var(--mono);
letter-spacing: -0.04em;
color: transparent;
-webkit-text-stroke: 1px rgb(120 216 229 / 38%);
pointer-events: none;
user-select: none;
}
@media (max-width: 720px) {
.portfolio-section > h2::before,
.project-stage__lead > h2::before { font-size: var(--step-3); }
}
@media (forced-colors: active) {
.portfolio-section > h2::before,
.project-stage__lead > h2::before,
body::after,
.reading-viewport::before,
:is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure)::after { display: none !important; }
}
/* Kinetic type + scroll-linked reveals (progressive enhancement). */
@media (prefers-reduced-motion: no-preference) {
.hero-name__text {
font-variation-settings: 'wght' 800;
transition: font-variation-settings 400ms ease, letter-spacing 400ms ease;
}
.hero-name:hover .hero-name__text {
font-variation-settings: 'wght' 520;
letter-spacing: 0.02em;
}
@supports (animation-timeline: view()) {
.reading-stage:not([data-reading-stage='live']) .project-card {
animation: ns-rise linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}
}
@keyframes ns-rise {
from { transform: translateY(28px); }
to { transform: none; }
}
@media print {
body::after, .ops-strip, .boot-sequence { display: none !important; }
.portfolio-section > h2::before,
.project-stage__lead > h2::before,
.reading-viewport::before,
:is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure)::after { display: none !important; }
}

240
src/styles/wayfinding.css Normal file
View File

@@ -0,0 +1,240 @@
/* Wayfinding: one sign language for the route. The roadside signs, chapter
plates, navigation, dash cluster and project plates share the same exit
numbers (from src/journey/sign-routes.ts) and the chapter accent colours.
Every label here is CSS generated content with empty alt text, so the
semantic document is identical with and without JavaScript. Imported last. */
:root {
--exit-ink: #160b22;
--exit-chamfer: polygon(5px 0, calc(100% - 5px) 0, 100% 5px, 100% 100%, 0 100%, 0 5px);
}
/* Static vignette above the world, below the UI. Plain alpha, no blend mode,
so the compositor never re-blends it against the animating canvas. */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: var(--z-atmosphere);
pointer-events: none;
background: radial-gradient(130% 95% at 50% 42%, transparent 58%, rgb(8 3 18 / 42%) 100%);
}
/* The hero scrim used to end in a hard edge that read as a dark band while
scrolling away from the hero. Feather its bottom with a static mask. */
.hero-journey::before {
mask-image: linear-gradient(to bottom, #000 72%, transparent);
}
/* ---------- Chapter exit plates ---------- */
.portfolio-section[data-exit] {
position: relative;
}
/* The exit tab sits on the panel's top edge, like the tab on a motorway sign. */
.portfolio-section[data-exit]::before {
content: 'EXIT ' attr(data-exit) / '';
position: absolute;
z-index: 1;
inset-block-start: 0;
inset-inline-start: clamp(1.1rem, 4vw, 3.2rem);
translate: 0 -50%;
padding: 0.42rem 0.7rem 0.38rem;
clip-path: var(--exit-chamfer);
background: var(--chapter-accent, var(--accent));
color: var(--exit-ink);
font: 720 0.7rem/1 var(--mono);
letter-spacing: 0.16em;
pointer-events: none;
}
/* "Then" line: the next exit, as on a real gantry. */
.portfolio-section[data-exit]::after {
content: 'THEN \2192 ' attr(data-next) / '';
position: absolute;
inset-block-start: 0;
inset-inline-end: clamp(1.1rem, 4vw, 3.2rem);
translate: 0 -50%;
padding: 0.38rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--chapter-accent, var(--accent)) 42%, transparent);
border-radius: 3px;
background: var(--surface-strong);
color: var(--text-muted);
font: 560 0.64rem/1 var(--mono);
letter-spacing: 0.12em;
text-transform: uppercase;
white-space: pre;
pointer-events: none;
}
.portfolio-section[data-exit='08']::after {
content: 'END OF ROUTE' / '';
}
/* The hero is the on-ramp. */
.hero-journey__content::before {
content: 'EXIT 01 \00b7 ON-RAMP \00b7 BUCHAREST' / '';
display: block;
inline-size: fit-content;
justify-self: start;
align-self: start;
margin-block-end: clamp(0.8rem, 2vh, 1.4rem);
padding: 0.42rem 0.7rem 0.38rem;
clip-path: var(--exit-chamfer);
background: var(--rose, var(--accent));
color: var(--exit-ink);
font: 720 0.68rem/1 var(--mono);
letter-spacing: 0.16em;
white-space: pre;
}
/* ---------- Navigation: the overhead gantry carries the same numbers ---------- */
.site-navigation ul {
counter-reset: exit;
}
.site-navigation li {
counter-increment: exit;
}
@media (min-width: 1100px) {
.site-navigation a::before {
content: counter(exit, decimal-leading-zero) / '';
margin-inline-end: 0.55em;
color: color-mix(in srgb, var(--nav-accent, var(--accent)) 70%, var(--text-muted));
font: 600 0.66em/1 var(--mono);
letter-spacing: 0.06em;
opacity: 0.8;
}
.site-navigation a[aria-current='page']::before {
opacity: 1;
}
}
/* ---------- Dash cluster: exit, chapter, route and local time in one unit ---------- */
.journey-hud[data-exit]::before {
content: 'EXIT ' attr(data-exit) / '';
padding: 0.3rem 0.45rem 0.26rem;
clip-path: var(--exit-chamfer);
background: var(--rose, var(--accent));
color: var(--exit-ink);
flex: none;
white-space: pre;
font: 720 0.62rem/1 var(--mono);
letter-spacing: 0.14em;
}
.journey-hud[data-exit='03']::before,
.journey-hud[data-exit='07']::before { background: var(--violet, var(--accent)); }
.journey-hud[data-exit='04']::before { background: var(--accent); }
.journey-hud[data-exit='05']::before,
.journey-hud[data-exit='08']::before { background: var(--coral, var(--amber)); }
.journey-hud[data-clock]::after {
content: attr(data-clock) ' BUC' / '';
padding-inline-start: 0.72rem;
border-inline-start: 1px solid rgb(126 220 232 / 22%);
color: var(--text-muted);
font-size: 0.66rem;
letter-spacing: 0.1em;
}
@media (max-width: 767px) {
.journey-hud[data-clock]::after { display: none; }
}
/* ---------- Project registration plates ---------- */
.project-case-study__header[data-plate]::before,
.project-card[data-plate]::before {
content: attr(data-plate) / '';
display: block;
inline-size: max-content;
max-inline-size: 100%;
margin-block-end: 0.85rem;
padding: 0.34rem 0.6rem 0.3rem;
border: 1px solid color-mix(in srgb, var(--chapter-accent, var(--accent)) 55%, transparent);
border-radius: 3px;
color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 72%, var(--text));
font: 650 0.64rem/1.2 var(--mono);
letter-spacing: 0.14em;
}
/* Case-study evidence reads as a route log: lettered stops. */
.project-evidence {
counter-reset: stop;
}
.project-evidence > div {
counter-increment: stop;
}
.project-evidence dt::before {
content: counter(stop, upper-alpha) / '';
display: inline-grid;
place-items: center;
inline-size: 1.45em;
block-size: 1.45em;
margin-inline-end: 0.6em;
border: 1px solid currentColor;
border-radius: 50%;
font: 700 0.78em/1 var(--mono);
vertical-align: 0.08em;
}
/* ---------- Arrival: the current chapter's plate strikes on, once ---------- */
.portfolio-section[data-current]::before {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--chapter-accent, var(--accent)) 60%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
.portfolio-section[data-current]::before {
animation: exit-strike 1.1s steps(1, end) both;
}
.portfolio-section[data-current] {
animation: exit-panel-light 1.6s cubic-bezier(0.2, 0.75, 0.2, 1) both;
}
@supports (animation-timeline: view()) {
.reading-stage:not([data-reading-stage='live']) .project-card {
animation: wf-rise linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}
}
/* A neon tube catching: two short flickers, then steady. */
@keyframes exit-strike {
0% { opacity: 0.25; }
12% { opacity: 1; }
18% { opacity: 0.35; }
30% { opacity: 1; }
36% { opacity: 0.55; }
48%, 100% { opacity: 1; }
}
@keyframes exit-panel-light {
0% { border-top-color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 95%, transparent); }
100% { border-top-color: color-mix(in srgb, var(--chapter-accent, var(--accent)) 28%, transparent); }
}
@keyframes wf-rise {
from { transform: translateY(28px); }
to { transform: none; }
}
/* ---------- Narrow screens ---------- */
@media (max-width: 520px) {
.portfolio-section[data-exit]::after,
.journey-hud[data-exit]::before { display: none; }
.hero-journey__content::before { content: 'EXIT 01 \00b7 ON-RAMP' / ''; }
}
/* ---------- Forced colours and print ---------- */
@media (forced-colors: active) {
body::after { display: none !important; }
.portfolio-section[data-exit]::before,
.hero-journey__content::before,
.journey-hud[data-exit]::before {
forced-color-adjust: none;
background: Canvas;
color: CanvasText;
border: 1px solid CanvasText;
clip-path: none;
}
}
@media print {
body::after,
.portfolio-section[data-exit]::before,
.portfolio-section[data-exit]::after,
.hero-journey__content::before,
.project-case-study__header[data-plate]::before,
.project-card[data-plate]::before { display: none !important; }
}

View File

@@ -1,21 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { bucharestClock, uptimeSince } from '@/lib/operations'; import { bucharestClock } from '@/lib/operations';
describe('uptimeSince', () => {
const start = new Date('2025-08-01T00:00:00Z');
it('is zero at the start', () => {
expect(uptimeSince(start, start)).toEqual({ months: 0, days: 0 });
});
it('counts whole months and remaining days', () => {
expect(uptimeSince(start, new Date('2026-10-10T00:00:00Z'))).toEqual({ months: 14, days: 9 });
});
it('clamps to zero before the start', () => {
expect(uptimeSince(start, new Date('2025-01-01T00:00:00Z'))).toEqual({ months: 0, days: 0 });
});
});
describe('bucharestClock', () => { describe('bucharestClock', () => {
it('formats HH:MM in Europe/Bucharest', () => { it('formats HH:MM in Europe/Bucharest', () => {
expect(bucharestClock(new Date('2026-01-15T10:05:00Z'))).toBe('12:05'); expect(bucharestClock(new Date('2026-01-15T10:05:00Z'))).toBe('12:05');
}); });
it('follows daylight saving time', () => {
expect(bucharestClock(new Date('2026-07-15T10:05:00Z'))).toBe('13:05');
});
}); });

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { routePlate } from '@/lib/route-plate';
describe('routePlate', () => {
it('combines the chapter exit, position and real project fields', () => {
expect(routePlate('05', 0, { visibility: 'private', maturity: 'active' })).toBe('05.1 / PRIVATE / ACTIVE');
});
it('spells multi-word maturity values without hyphens', () => {
expect(routePlate('06', 2, { visibility: 'public', maturity: 'work-in-progress' })).toBe('06.3 / PUBLIC / WORK IN PROGRESS');
});
it('falls back to the position when no exit is known', () => {
expect(routePlate(undefined, 1, { visibility: 'public', maturity: 'learning' })).toBe('2 / PUBLIC / LEARNING');
});
});