From d16ce88639c2c2453bae9de06c09f8b7009a6513 Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 14:13:26 +0300 Subject: [PATCH] feat: add Night Shift layer (boot intro, ops strip, headlight, chapter numerals, film grain) --- src/App.vue | 1 + src/components/AppShell.vue | 6 + src/components/BootSequence.vue | 55 +++++++++ src/components/OperationsStrip.vue | 33 +++++ src/composables/useHeadlight.ts | 50 ++++++++ src/lib/operations.ts | 38 ++++++ src/styles/nightshift.css | 185 +++++++++++++++++++++++++++++ tests/unit/operations.test.ts | 21 ++++ 8 files changed, 389 insertions(+) create mode 100644 src/components/BootSequence.vue create mode 100644 src/components/OperationsStrip.vue create mode 100644 src/composables/useHeadlight.ts create mode 100644 src/lib/operations.ts create mode 100644 src/styles/nightshift.css create mode 100644 tests/unit/operations.test.ts diff --git a/src/App.vue b/src/App.vue index 6df9779..eeb7cd5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,6 +10,7 @@ import './styles/evolution.css'; import './styles/chromatic.css'; import './styles/refinements.css'; import './styles/title-navigation.css'; +import './styles/nightshift.css'; import './styles/print.css'; defineProps<{ inputs: Readonly }>(); diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index bbe978f..024f596 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -2,10 +2,12 @@ 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'; @@ -16,6 +18,7 @@ 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'; @@ -59,6 +62,7 @@ const sectionLabel = (id: SectionId): string => { 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, @@ -211,6 +215,8 @@ onUnmounted(() => removePageListeners()); :sample-frame="sampleSceneFrame" @state-change="updateSceneState" /> + + +import { onMounted, onUnmounted, shallowRef } 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(); + +onMounted(() => { + let seen = false; + try { seen = window.sessionStorage.getItem(SEEN_KEY) === '1'; } catch { seen = false; } + if (!props.allowMotion || seen || navigator.webdriver) 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 }); +}); + +onUnmounted(() => { + timers.forEach((id) => window.clearTimeout(id)); + window.removeEventListener('keydown', onKey); +}); + + + diff --git a/src/components/OperationsStrip.vue b/src/components/OperationsStrip.vue new file mode 100644 index 0000000..e530341 --- /dev/null +++ b/src/components/OperationsStrip.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/composables/useHeadlight.ts b/src/composables/useHeadlight.ts new file mode 100644 index 0000000..7c8231d --- /dev/null +++ b/src/composables/useHeadlight.ts @@ -0,0 +1,50 @@ +import { onMounted, onUnmounted, 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): 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(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); + }; + + 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; + }); +} diff --git a/src/lib/operations.ts b/src/lib/operations.ts new file mode 100644 index 0000000..6535f97 --- /dev/null +++ b/src/lib/operations.ts @@ -0,0 +1,38 @@ +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', { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + timeZone: 'Europe/Bucharest', +}); + +export function bucharestClock(now: Date): string { + return clockFormat.format(now); +} diff --git a/src/styles/nightshift.css b/src/styles/nightshift.css new file mode 100644 index 0000000..9b37783 --- /dev/null +++ b/src/styles/nightshift.css @@ -0,0 +1,185 @@ +/* Night Shift: cinematic layer on top of the night-drive world. Imported last. */ + +:root { + --ns-grain: url("data:image/svg+xml;utf8,"); +} + +/* 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 strong { color: var(--text); font-weight: 600; } +.ops-strip__sep { opacity: 0.4; } +.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: var(--z-atmosphere); + 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; +} +html[data-headlight='on'] :is(.project-case-study, .project-card, .contact-destination, .infrastructure-flow-figure)::after { + content: ''; + position: absolute; + inset: 0; + 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-end: 100%; + inset-inline-start: 0; + margin-block-end: 0.15em; + 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 { display: none; } +} + +/* 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()) { + .portfolio-section > h2::before, + .project-stage__lead > h2::before { + animation: ns-drift linear both; + animation-timeline: view(); + animation-range: entry 0% cover 60%; + } + .project-card, + .project-case-study { + animation: ns-rise linear both; + animation-timeline: view(); + animation-range: entry 0% entry 60%; + } + } +} +@keyframes ns-drift { + from { transform: translateX(-12%); opacity: 0; } + to { transform: none; opacity: 1; } +} +@keyframes ns-rise { + from { transform: translateY(28px); opacity: 0.2; } + to { transform: none; opacity: 1; } +} + +@media print { + body::after, .ops-strip, .boot-sequence { display: none !important; } + .portfolio-section > h2::before { display: none; } +} diff --git a/tests/unit/operations.test.ts b/tests/unit/operations.test.ts new file mode 100644 index 0000000..da12d5d --- /dev/null +++ b/tests/unit/operations.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { bucharestClock, uptimeSince } 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', () => { + it('formats HH:MM in Europe/Bucharest', () => { + expect(bucharestClock(new Date('2026-01-15T10:05:00Z'))).toBe('12:05'); + }); +});