From 6c011463de1d4492b993672b1be3d29b7d1ac515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Montes=20Linares?= <128893712+aMonteSl@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:37:47 +0200 Subject: [PATCH 1/2] feat(journey): close SATEC and announce the URJC research role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SATEC Cloud & Systems N2 internship ends on 2026-09-28. - New work entry: Specialist Technician in the URJC research project "Dependencias en colecciones complejas de módulos software" (PID2022-139551NB-I00, AEI), starting 2026-10-08. - Hero, profile and JSON-LD copy switch from "upcoming" to "current" on the start date: in the browser with the visitor's date, and in the prerendered HTML through the build date (NEXT_PUBLIC_BUILD_DATE) that the daily deploy refreshes. - The timeline draws entries that have not started yet as dashed markers on their start date, with a "Starts · Upcoming" detail card. Co-Authored-By: Claude Opus 5.5 --- next.config.ts | 6 ++ scripts/check-journey-timeline.mjs | 17 +++- src/content/journey.ts | 36 ++++++-- .../journey/ParallelStreamsSection.tsx | 84 ++++++++----------- src/features/journey/StreamCard.tsx | 12 +++ src/features/journey/VerticalTimeline.tsx | 11 ++- src/features/journey/index.ts | 1 + src/features/journey/timelineConfig.ts | 7 ++ src/features/journey/timelineMath.ts | 9 +- src/features/journey/useJourneyToday.ts | 39 +++++++++ src/features/landing/Hero.tsx | 13 ++- src/features/profile/ProfileBio.tsx | 11 ++- src/i18n/messages/en.json | 35 ++++++-- src/i18n/messages/es.json | 35 ++++++-- src/lib/seo.ts | 21 ++++- 15 files changed, 247 insertions(+), 90 deletions(-) create mode 100644 src/features/journey/useJourneyToday.ts diff --git a/next.config.ts b/next.config.ts index 81c446d..6ba93f2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,8 +3,14 @@ import type { NextConfig } from "next"; const isProd = process.env.NODE_ENV === 'production'; const isUserPages = true; // Este es un repo .github.io +// Fecha del build (YYYY-MM-DD, hora de Madrid): "hoy" con el que se pre-renderiza el HTML estático +const buildDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Madrid' }).format(new Date()); + const nextConfig: NextConfig = { output: "export", // Imprescindible para GitHub Pages + env: { + NEXT_PUBLIC_BUILD_DATE: buildDate, + }, trailingSlash: true, // Ayuda con rutas relativas en GH Pages distDir: isProd ? 'out' : '.next-dev', // Evita conflictos: .next-dev en dev, out en prod images: { diff --git a/scripts/check-journey-timeline.mjs b/scripts/check-journey-timeline.mjs index b703bfd..e7fa638 100644 --- a/scripts/check-journey-timeline.mjs +++ b/scripts/check-journey-timeline.mjs @@ -18,9 +18,13 @@ function isPastHalfOfYear(today) { return dateToYearPercent(today) > 50; } +function isUpcomingEntry(entry, today) { + return toLocalDate(getEntryStartDate(entry)).getTime() > toLocalDate(today).getTime(); +} + function getEntryEndDate(entry, today) { if (entry.endYear === null) { - return today; + return isUpcomingEntry(entry, today) ? getEntryStartDate(entry) : today; } return { @@ -159,4 +163,15 @@ assert(getVisibleTimelineYears(planned2028Entries, beforeHalf, 2020, 2026).at(-1 assert(hasScheduledContentInYear(baseEntries, 2027, nextYear), 'Ongoing entries must occupy the current new year.'); +const upcomingRole = { id: 'urjcResearch', startYear: 2026, startMonth: 10, startDay: 8, endYear: null }; +const beforeStart = { year: 2026, month: 10, day: 5 }; +const onStart = { year: 2026, month: 10, day: 8 }; +const afterStart = { year: 2026, month: 11, day: 2 }; +const sameDate = (a, b) => a.year === b.year && a.month === b.month && a.day === b.day; + +assert(isUpcomingEntry(upcomingRole, beforeStart), 'An open entry starting after today must be upcoming.'); +assert(sameDate(getEntryEndDate(upcomingRole, beforeStart), getEntryStartDate(upcomingRole)), 'An upcoming open entry must end on its start date, never before it.'); +assert(!isUpcomingEntry(upcomingRole, onStart), 'An entry must stop being upcoming on its start date.'); +assert(sameDate(getEntryEndDate(upcomingRole, afterStart), afterStart), 'A started open entry must run until today.'); + console.log('Journey timeline checks passed.'); diff --git a/src/content/journey.ts b/src/content/journey.ts index eb378b0..d6dbaf5 100644 --- a/src/content/journey.ts +++ b/src/content/journey.ts @@ -1,4 +1,5 @@ import { JourneyEntry, LaneConfig } from '@/features/journey/types' +import { parseJourneyDate, type JourneyDate } from '@/features/journey/timelineMath' import { LINKS } from '@/lib/constants' /** @@ -118,10 +119,26 @@ export const JOURNEY_ENTRIES: JourneyEntry[] = [ startYear: 2026, startMonth: 3, startDay: 14, - endYear: null, + endYear: 2026, + endMonth: 9, + endDay: 28, tags: ['Cloud', 'Systems', 'N2 Support', 'Infrastructure'], link: 'https://www.satec.es', }, + { + id: 'urjcResearch', + type: 'work', + lane: 'work', + roleKey: 'journey.entries.urjcResearch.role', + orgKey: 'journey.entries.urjcResearch.org', + descKey: 'journey.entries.urjcResearch.desc', + startYear: 2026, + startMonth: 10, + startDay: 8, + endYear: null, + tags: ['Research', 'Software Ecosystems', 'Dependencies', 'Open Source'], + link: 'https://www.urjc.es', + }, // === PROJECT (Lane 3) === { @@ -200,11 +217,20 @@ export const JOURNEY_ENTRIES: JourneyEntry[] = [ /** Timeline boundaries */ export const TIMELINE_START = 2020 export const TIMELINE_END = 2026 -export const CURRENT_DATE = { + +/** + * "Today" the static HTML is prerendered with: the build date (Madrid time) injected by + * next.config.ts, identical in the server and client bundles so hydration never mismatches. + * The visitor's real date replaces it after hydration (see useJourneyToday). + */ +export const BUILD_DATE: JourneyDate = parseJourneyDate(process.env.NEXT_PUBLIC_BUILD_DATE) ?? { year: 2026, - month: 9, - day: 7, -} as const + month: 10, + day: 5, +} + +/** Work entry the hero and profile describe as the current (or upcoming) role */ +export const CURRENT_ROLE_ID = 'urjcResearch' /** Convert a date to a decimal year (for precise positioning) */ export function dateToDecimalYear(year: number, month?: number, day?: number): number { diff --git a/src/features/journey/ParallelStreamsSection.tsx b/src/features/journey/ParallelStreamsSection.tsx index 3bdd14b..52f6276 100644 --- a/src/features/journey/ParallelStreamsSection.tsx +++ b/src/features/journey/ParallelStreamsSection.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo, useCallback, useEffect, useRef } from 'react' +import { useState, useMemo, useCallback, useRef } from 'react' import { motion, AnimatePresence, useInView } from 'framer-motion' import { SectionHeader, SectionShell } from '@/components/ui' import { useLocale, useTranslations } from '@/i18n' @@ -13,6 +13,7 @@ import { StreamCard } from './StreamCard' import { StreamLegend } from './StreamLegend' import { VerticalTimeline } from './VerticalTimeline' import { useGlowAnimation } from './useGlowAnimation' +import { useJourneyToday } from './useJourneyToday' import { MARKER_ROW_CLASSES, staggerMarkers, type MarkerRow } from './staggerMarkers' import type { ActiveHighlightRef, JourneyEntry, JourneyLane, JourneyHighlight } from './types' import { @@ -24,10 +25,8 @@ import { getHighlightDate, getVisibleTimelineYears, getYearState, - isFutureLearningEntry, isPointEntry, - parseJourneyDate, - toJourneyDate, + isUpcomingEntry, toLocalDate, type JourneyDate, } from './timelineMath' @@ -40,7 +39,6 @@ import { LANE_CONFIG, TIMELINE_START, TIMELINE_END, - CURRENT_DATE, } from '@/content/journey' /** Touch-only hit areas for the horizontal layout (tablets at lg+); a mouse never sees them */ @@ -110,12 +108,6 @@ function formatPeriod( return `${start} - ${end}` } -declare global { - interface Window { - __JOURNEY_TODAY__?: string - } -} - function dateToTimelinePercent(date: JourneyDate, timelineEndYear: number): number { return getTimelinePercent(date, TIMELINE_START, timelineEndYear) } @@ -124,17 +116,6 @@ function isSameHighlight(a: ActiveHighlightRef | null, b: ActiveHighlightRef): b return Boolean(a && a.entryId === b.entryId && a.highlightId === b.highlightId) } -function useJourneyToday(): JourneyDate { - const [today, setToday] = useState(CURRENT_DATE) - - useEffect(() => { - const queryToday = new URLSearchParams(window.location.search).get('journeyToday') ?? undefined - setToday(parseJourneyDate(window.__JOURNEY_TODAY__) ?? parseJourneyDate(queryToday) ?? toJourneyDate(new Date())) - }, []) - - return today -} - /** * ParallelStreamsSection * Professional journey visualization with year drill-down @@ -220,6 +201,15 @@ export function ParallelStreamsSection() { const todayLabel = useMemo(() => formatHighlightDate(today), [formatHighlightDate, today]) + /** Period shown for an entry; one that has not started yet shows its start date instead */ + const getPeriodLabel = useCallback((entry: JourneyEntry) => { + if (isUpcomingEntry(entry, today)) { + return t('startsOn', { date: formatHighlightDate(getEntryStartDate(entry)) }) + } + + return formatPeriod(entry.startYear, entry.startMonth, entry.endYear, entry.endMonth, t('present'), locale) + }, [formatHighlightDate, locale, t, today]) + const activeHighlight = useMemo(() => { if (!activeEntry || !activeHighlightRef || activeEntry.id !== activeHighlightRef.entryId) { return null @@ -337,10 +327,12 @@ export function ParallelStreamsSection() { endMonth = entry.endMonth ?? 12 endDay = entry.endDay ?? 31 } else if (entry.endYear === null) { - if (year === today.year) { - endMonth = today.month - endDay = today.day - } else if (year < today.year) { + // Open entries run until today, or end on their start date while they have not started + const openEnd = getEntryEndDate(entry, today) + if (year === openEnd.year) { + endMonth = openEnd.month + endDay = openEnd.day + } else if (year < openEnd.year) { endMonth = 12 endDay = 31 } else { @@ -618,14 +610,7 @@ export function ParallelStreamsSection() { lane={activeEntry.lane} title={t(`entries.${activeEntry.id}.role`)} organization={t(`entries.${activeEntry.id}.org`)} - period={formatPeriod( - activeEntry.startYear, - activeEntry.startMonth, - activeEntry.endYear, - activeEntry.endMonth, - t('present'), - locale - )} + period={getPeriodLabel(activeEntry)} description={t(`entries.${activeEntry.id}.desc`)} highlights={activeEntry.highlights?.map((h) => ({ id: h.id, @@ -637,8 +622,10 @@ export function ParallelStreamsSection() { activeHighlightLabel={t('selectedMilestone')} tags={activeEntry.tags} link={activeEntry.link} - isOngoing={activeEntry.endYear === null} + isOngoing={activeEntry.endYear === null && !isUpcomingEntry(activeEntry, today)} ongoingLabel={t('ongoing')} + isUpcoming={isUpcomingEntry(activeEntry, today)} + upcomingLabel={t('upcoming')} moreLabel={t('viewMore')} bodyClassName="max-lg:max-h-[45svh] max-lg:overflow-y-auto max-lg:overscroll-contain max-lg:rounded-xl" onClose={clearSelection} @@ -832,12 +819,13 @@ export function ParallelStreamsSection() { const endPercent = getEndPercent(entry) const isOngoing = entry.endYear === null const isActive = hoveredEntry === entry.id || selectedEntry === entry.id - const isPointEvent = isPointEntry(entry) - const isFutureLearning = isFutureLearningEntry(entry, today) + const isUpcoming = isUpcomingEntry(entry, today) + // Entries that have not started yet collapse to a planned marker on their start date + const isPointEvent = isPointEntry(entry) || isUpcoming const glowIntensity = getIntensity(entry.id) // Dynamic glow style - const glowStyle = !isFutureLearning && glowIntensity > 0 ? { + const glowStyle = !isUpcoming && glowIntensity > 0 ? { boxShadow: `0 0 ${20 * glowIntensity}px ${colors.hex}`, filter: `brightness(${1 + 0.3 * glowIntensity})`, } : {} @@ -851,8 +839,8 @@ export function ParallelStreamsSection() { 'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 cursor-pointer', 'w-5 h-5 sm:w-6 sm:h-6 rounded-full', COARSE_HIT_DOT, - isFutureLearning - ? 'border border-dashed border-pink-200/45 bg-pink-400/25 ring-4 ring-pink-400/10 opacity-65 hover:opacity-100' + isUpcoming + ? [colors.planned, 'opacity-65 hover:opacity-100'] : [colors.bg, 'ring-2 ring-[var(--bg)]'], isActive && 'scale-125', hoveredEntry && !isActive && glowIntensity === 0 && 'opacity-40', @@ -873,7 +861,7 @@ export function ParallelStreamsSection() { onPointerMove={() => handleEntryHoverStart(entry.id)} onPointerLeave={() => handleEntryHoverEnd(entry.id)} onClick={(event) => handleEntryClick(entry.id, event)} - title={`${t(`entries.${entry.id}.role`)} - ${formatPeriod(entry.startYear, entry.startMonth, entry.endYear, entry.endMonth, t('present'), locale)}`} + title={`${t(`entries.${entry.id}.role`)} - ${getPeriodLabel(entry)}`} aria-label={t(`entries.${entry.id}.role`)} /> ) @@ -1078,12 +1066,12 @@ export function ParallelStreamsSection() { const { startPercent, endPercent } = range const isActive = hoveredEntry === entry.id || selectedEntry === entry.id - const isPointEvent = isPointEntry(entry) - const isFutureLearning = isFutureLearningEntry(entry, today) + const isUpcoming = isUpcomingEntry(entry, today) + const isPointEvent = isPointEntry(entry) || isUpcoming if (isPointEvent) { const glowIntensity = getIntensity(entry.id) - const glowStyle = !isFutureLearning && glowIntensity > 0 ? { + const glowStyle = !isUpcoming && glowIntensity > 0 ? { boxShadow: `0 0 ${16 * glowIntensity}px ${colors.hex}`, filter: `brightness(${1 + 0.25 * glowIntensity})`, } : {} @@ -1095,8 +1083,8 @@ export function ParallelStreamsSection() { 'absolute top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 cursor-pointer', 'w-5 h-5 sm:w-6 sm:h-6 rounded-full', COARSE_HIT_DOT, - isFutureLearning - ? 'border border-dashed border-pink-200/45 bg-pink-400/25 ring-4 ring-pink-400/10 opacity-65 hover:opacity-100' + isUpcoming + ? [colors.planned, 'opacity-65 hover:opacity-100'] : [colors.bg, 'ring-2 ring-[var(--bg)]'], isActive && 'scale-150 shadow-lg', hoveredEntry && !isActive && glowIntensity === 0 && 'opacity-50', @@ -1113,7 +1101,7 @@ export function ParallelStreamsSection() { onPointerMove={() => handleEntryHoverStart(entry.id)} onPointerLeave={() => handleEntryHoverEnd(entry.id)} onClick={(event) => handleEntryClick(entry.id, event)} - title={`${t(`entries.${entry.id}.role`)} - ${formatPeriod(entry.startYear, entry.startMonth, entry.endYear, entry.endMonth, t('present'), locale)}`} + title={`${t(`entries.${entry.id}.role`)} - ${getPeriodLabel(entry)}`} aria-label={t(`entries.${entry.id}.role`)} /> ) @@ -1153,7 +1141,7 @@ export function ParallelStreamsSection() { onPointerMove={() => handleEntryHoverStart(entry.id)} onPointerLeave={() => handleEntryHoverEnd(entry.id)} onClick={(event) => handleEntryClick(entry.id, event)} - title={`${t(`entries.${entry.id}.role`)} - ${formatPeriod(entry.startYear, entry.startMonth, entry.endYear, entry.endMonth, t('present'), locale)}`} + title={`${t(`entries.${entry.id}.role`)} - ${getPeriodLabel(entry)}`} > {/* Animated pulse for recent events */} {isRecent && !isActive && ( diff --git a/src/features/journey/StreamCard.tsx b/src/features/journey/StreamCard.tsx index 83b9e2a..2330e4f 100644 --- a/src/features/journey/StreamCard.tsx +++ b/src/features/journey/StreamCard.tsx @@ -30,6 +30,10 @@ interface StreamCardProps { isOngoing?: boolean /** Localized ongoing status label */ ongoingLabel?: string + /** Has this entry not started yet? */ + isUpcoming?: boolean + /** Localized upcoming status label */ + upcomingLabel?: string /** Localized external link label */ moreLabel?: string /** Additional className for the card surface */ @@ -94,6 +98,8 @@ export function StreamCard({ link, isOngoing = false, ongoingLabel = 'Active', + isUpcoming = false, + upcomingLabel = 'Upcoming', moreLabel = 'View more', className, bodyClassName, @@ -156,6 +162,12 @@ export function StreamCard({ {ongoingLabel} )} + {isUpcoming && ( + + + )} diff --git a/src/features/journey/VerticalTimeline.tsx b/src/features/journey/VerticalTimeline.tsx index 1272687..9c8680d 100644 --- a/src/features/journey/VerticalTimeline.tsx +++ b/src/features/journey/VerticalTimeline.tsx @@ -10,8 +10,8 @@ import { getEntryEndDate, getEntryStartDate, getHighlightDate, - isFutureLearningEntry, isPointEntry, + isUpcomingEntry, type JourneyDate, type TimelineYearState, } from './timelineMath' @@ -174,12 +174,13 @@ export function VerticalTimeline({ hoveredEntry === entry.id || selectedEntry === entry.id || selectedHighlight?.entryId === entry.id - const isPointEvent = isPointEntry(entry) + const isUpcoming = isUpcomingEntry(entry, today) + // Entries that have not started yet collapse to a planned marker on their start date + const isPointEvent = isPointEntry(entry) || isUpcoming const startTop = getVerticalTop(getEntryStartDate(entry)) const endTop = getVerticalTop(getEntryEndDate(entry, today)) const top = isPointEvent ? startTop : Math.min(startTop, endTop) const height = Math.max(Math.abs(startTop - endTop), 2) - const isFutureLearning = isFutureLearningEntry(entry, today) const label = t(`entries.${entry.id}.role`) if (isPointEvent) { @@ -190,9 +191,7 @@ export function VerticalTimeline({ className={cn( 'absolute z-20 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-[var(--bg)] transition-all duration-200 hover:scale-125 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]', HIT_AREA_POINT, - isFutureLearning - ? 'border border-dashed border-pink-200/45 bg-pink-400/30 opacity-70' - : colors.bg, + isUpcoming ? [colors.planned, 'opacity-70'] : colors.bg, isActive && 'scale-125 shadow-[0_0_18px_rgba(238,174,148,0.45)]' )} style={{ top: `${top}%` }} diff --git a/src/features/journey/index.ts b/src/features/journey/index.ts index debb966..b7a8b6e 100644 --- a/src/features/journey/index.ts +++ b/src/features/journey/index.ts @@ -8,6 +8,7 @@ export { VerticalTimeline } from './VerticalTimeline' // Hooks export { useGlowAnimation } from './useGlowAnimation' +export { useEntryPhase, useJourneyToday, type EntryPhase } from './useJourneyToday' // Layout helpers export { MARKER_MIN_GAP_PX, MARKER_ROW_CLASSES, staggerMarkers } from './staggerMarkers' diff --git a/src/features/journey/timelineConfig.ts b/src/features/journey/timelineConfig.ts index 3199b29..e5ca484 100644 --- a/src/features/journey/timelineConfig.ts +++ b/src/features/journey/timelineConfig.ts @@ -7,36 +7,43 @@ export const LANE_COLORS: Record = { education: { bg: 'bg-blue-500', hex: '#3b82f6', ring: 'ring-blue-500/30', text: 'text-blue-400', + planned: 'border border-dashed border-blue-200/45 bg-blue-400/25 ring-4 ring-blue-400/10', }, work: { bg: 'bg-emerald-500', hex: '#10b981', ring: 'ring-emerald-500/30', text: 'text-emerald-400', + planned: 'border border-dashed border-emerald-200/45 bg-emerald-400/25 ring-4 ring-emerald-400/10', }, project: { bg: 'bg-violet-500', hex: '#8b5cf6', ring: 'ring-violet-500/30', text: 'text-violet-400', + planned: 'border border-dashed border-violet-200/45 bg-violet-400/25 ring-4 ring-violet-400/10', }, achievement: { bg: 'bg-amber-500', hex: '#f59e0b', ring: 'ring-amber-500/30', text: 'text-amber-400', + planned: 'border border-dashed border-amber-200/45 bg-amber-400/25 ring-4 ring-amber-400/10', }, learning: { bg: 'bg-pink-500', hex: '#ec4899', ring: 'ring-pink-500/30', text: 'text-pink-400', + planned: 'border border-dashed border-pink-200/45 bg-pink-400/25 ring-4 ring-pink-400/10', }, } diff --git a/src/features/journey/timelineMath.ts b/src/features/journey/timelineMath.ts index 6db1701..9184bdf 100644 --- a/src/features/journey/timelineMath.ts +++ b/src/features/journey/timelineMath.ts @@ -78,7 +78,8 @@ export function getEntryStartDate(entry: JourneyEntry): JourneyDate { export function getEntryEndDate(entry: JourneyEntry, today: JourneyDate): JourneyDate { if (entry.endYear === null) { - return today + // An open entry that has not started yet ends where it starts, never before it + return isUpcomingEntry(entry, today) ? getEntryStartDate(entry) : today } return { @@ -105,9 +106,9 @@ export function isPointEntry(entry: JourneyEntry): boolean { ) } -/** Learning entries scheduled after today render as planned (dashed) items */ -export function isFutureLearningEntry(entry: JourneyEntry, today: JourneyDate): boolean { - return entry.lane === 'learning' && toLocalDate(getEntryStartDate(entry)).getTime() > toLocalDate(today).getTime() +/** Entries that start after today render as planned (dashed) markers on their start date */ +export function isUpcomingEntry(entry: JourneyEntry, today: JourneyDate): boolean { + return toLocalDate(getEntryStartDate(entry)).getTime() > toLocalDate(today).getTime() } export function isPastHalfOfYear(today: JourneyDate): boolean { diff --git a/src/features/journey/useJourneyToday.ts b/src/features/journey/useJourneyToday.ts new file mode 100644 index 0000000..05c8309 --- /dev/null +++ b/src/features/journey/useJourneyToday.ts @@ -0,0 +1,39 @@ +'use client' + +import { useEffect, useState } from 'react' +import { BUILD_DATE, JOURNEY_ENTRIES } from '@/content/journey' +import { isUpcomingEntry, parseJourneyDate, toJourneyDate, type JourneyDate } from './timelineMath' + +declare global { + interface Window { + __JOURNEY_TODAY__?: string + } +} + +/** + * The date the journey is drawn for. + * + * Starts at the build date so the prerendered markup hydrates identically, then + * switches to `window.__JOURNEY_TODAY__`, the `?journeyToday=YYYY-MM-DD` query + * parameter, or the visitor's real date. + */ +export function useJourneyToday(): JourneyDate { + const [today, setToday] = useState(BUILD_DATE) + + useEffect(() => { + const queryToday = new URLSearchParams(window.location.search).get('journeyToday') ?? undefined + setToday(parseJourneyDate(window.__JOURNEY_TODAY__) ?? parseJourneyDate(queryToday) ?? toJourneyDate(new Date())) + }, []) + + return today +} + +export type EntryPhase = 'upcoming' | 'current' + +/** Whether a journey entry has started yet, so copy can switch on its start date */ +export function useEntryPhase(entryId: string): EntryPhase { + const today = useJourneyToday() + const entry = JOURNEY_ENTRIES.find((item) => item.id === entryId) + + return entry && isUpcomingEntry(entry, today) ? 'upcoming' : 'current' +} diff --git a/src/features/landing/Hero.tsx b/src/features/landing/Hero.tsx index 5ce55cf..f9ceb32 100644 --- a/src/features/landing/Hero.tsx +++ b/src/features/landing/Hero.tsx @@ -20,6 +20,8 @@ import { useTranslations, useLocale } from '@/i18n' import { SOCIAL_LINKS, getCvUrl } from '@/lib/constants' import { useEmailCopyFeedback } from '@/lib/hooks/useEmailCopyFeedback' import { FEATURED_PROJECTS } from '@/content/featuredProjects' +import { CURRENT_ROLE_ID } from '@/content/journey' +import { useEntryPhase } from '@/features/journey' import { useFeaturedRotation } from './useFeaturedRotation' import { FeaturedProjectCard } from './FeaturedProjectCard' @@ -58,6 +60,9 @@ export function Hero() { } = useFeaturedRotation(FEATURED_PROJECTS) const cvUrl = getCvUrl(locale) const { copiedEmail, copyEmail } = useEmailCopyFeedback() + // Copy about the current role switches on its start date (see useEntryPhase) + const isRoleUpcoming = useEntryPhase(CURRENT_ROLE_ID) === 'upcoming' + const roleMetric = isRoleUpcoming ? 'metrics.upcoming' : 'metrics.current' const socialLabels: Record = { github: t('social.github'), linkedin: t('social.linkedin'), @@ -126,14 +131,14 @@ export function Hero() { - {t('availabilityLabel')} + {t(isRoleUpcoming ? 'availabilityLabelUpcoming' : 'availabilityLabel')} - {t('availabilityText')} + {t(isRoleUpcoming ? 'availabilityTextUpcoming' : 'availabilityText')} {t('location')} - {t('aboutMe')} + {t(isRoleUpcoming ? 'aboutMeUpcoming' : 'aboutMe')} @@ -173,7 +178,7 @@ export function Hero() { - + diff --git a/src/features/profile/ProfileBio.tsx b/src/features/profile/ProfileBio.tsx index ef8263d..0ce0f82 100644 --- a/src/features/profile/ProfileBio.tsx +++ b/src/features/profile/ProfileBio.tsx @@ -5,6 +5,8 @@ import { fadeInUp } from '@/lib/motion' import { useTranslations } from '@/i18n' import { cn } from '@/lib/utils' import { DividerLine, Kicker, MetricTile, StatusPill, Surface } from '@/components/ui' +import { CURRENT_ROLE_ID } from '@/content/journey' +import { useEntryPhase } from '@/features/journey' interface ProfileBioProps { className?: string @@ -12,16 +14,19 @@ interface ProfileBioProps { export function ProfileBio({ className }: ProfileBioProps) { const t = useTranslations('profile') + // Copy about the current role switches on its start date (see useEntryPhase) + const isRoleUpcoming = useEntryPhase(CURRENT_ROLE_ID) === 'upcoming' const pills = [ - { label: t('pills.satec'), tone: 'success' as const }, + { label: t(isRoleUpcoming ? 'pills.researchUpcoming' : 'pills.research'), tone: 'success' as const }, + { label: t('pills.satec'), tone: 'accent' as const }, { label: t('pills.vbgroup'), tone: 'accent' as const }, { label: t('pills.upm'), tone: 'xr' as const }, { label: t('pills.english'), tone: 'success' as const }, ] const stats = [ - { label: t('stats.experience'), value: t('stats.experienceValue') }, + { label: t('stats.experience'), value: t(isRoleUpcoming ? 'stats.experienceValueUpcoming' : 'stats.experienceValue') }, { label: t('stats.education'), value: t('stats.educationValue') }, { label: t('stats.focus'), value: t('stats.focusValue') }, { label: t('stats.languages'), value: t('stats.languagesValue') }, @@ -51,7 +56,7 @@ export function ProfileBio({ className }: ProfileBioProps) {

{t('bio1')}

-

{t('bio2')}

+

{t(isRoleUpcoming ? 'bio2Upcoming' : 'bio2')}

{t('bio3')}

diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 75fb050..07720d6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -29,11 +29,14 @@ "name": "Adrián Montes Linares", "headline": "Telematics Engineer · Master's student in Telecommunications Engineering", "headlineSub": "Systems, Cloud, Full-Stack and XR developer tools", - "aboutMe": "Telematics Engineer from URJC, graduated in January 2026, with experience in software development, systems, and data visualization. Author of Code-XR, an open-source VS Code extension published at IEEE VISSOFT 2025, focused on visual analysis of code metrics in XR environments. Currently enrolled in the Master's Degree in Telecommunications Engineering at UPM, specializing in Machine Learning and Big Data, while continuing an extracurricular Cloud & Systems N2 internship at SATEC. Interested in software analytics, code repositories, and applied research.", + "aboutMe": "Telematics Engineer from URJC, graduated in January 2026, with experience in software development, systems, and data visualization. Author of Code-XR, an open-source VS Code extension published at IEEE VISSOFT 2025, focused on visual analysis of code metrics in XR environments. Currently enrolled in the Master's Degree in Telecommunications Engineering at UPM, specializing in Machine Learning and Big Data, while working as a Specialist Technician on a URJC research project on dependencies in complex collections of software modules, after an extracurricular Cloud & Systems N2 internship at SATEC. Interested in software analytics, code repositories, and applied research.", + "aboutMeUpcoming": "Telematics Engineer from URJC, graduated in January 2026, with experience in software development, systems, and data visualization. Author of Code-XR, an open-source VS Code extension published at IEEE VISSOFT 2025, focused on visual analysis of code metrics in XR environments. Currently enrolled in the Master's Degree in Telecommunications Engineering at UPM, specializing in Machine Learning and Big Data. After an extracurricular Cloud & Systems N2 internship at SATEC, joining a URJC research project on dependencies in complex collections of software modules as a Specialist Technician on October 8, 2026. Interested in software analytics, code repositories, and applied research.", "ctaProjects": "View Projects", "ctaResume": "Download CV", "availabilityLabel": "Currently active", - "availabilityText": "SATEC internship, since Mar 2026", + "availabilityLabelUpcoming": "Starting soon", + "availabilityText": "URJC research, since Oct 2026", + "availabilityTextUpcoming": "URJC research, from Oct 8", "location": "Madrid, ES", "featuredProject": { "badge": "Featured", @@ -63,8 +66,13 @@ "metrics": { "current": { "label": "Current", - "value": "Cloud & Systems N2", - "detail": "SATEC · since Mar 2026" + "value": "Specialist Technician", + "detail": "URJC research · since Oct 2026" + }, + "upcoming": { + "label": "Upcoming", + "value": "Specialist Technician", + "detail": "URJC research · from Oct 8, 2026" }, "research": { "label": "Research", @@ -90,10 +98,12 @@ "name": "Adrián Montes Linares", "role": "Telematics Engineer and Master's student in Telecommunications Engineering (UPM), with experience in Cloud, Systems N2, Full-Stack development, and XR software visualization", "bio1": "I am a Telematics Engineer from Universidad Rey Juan Carlos, graduated in January 2026. My profile combines software development, systems, cloud operations, and data visualization, with a practical focus on maintainable tools and clear documentation.", - "bio2": "I am currently combining the master's degree at UPM with an extracurricular internship at SATEC in Cloud & Systems N2, where I work on support requests, incidents, infrastructure operation, virtual machines, vulnerability patching, SSH/RDP access, and tools such as Nagios, AMS, Veeam Backup, NetApp, and VMware.", + "bio2": "I currently combine the master's degree at UPM with a Specialist Technician position in the URJC research project “Dependencies in complex collections of software modules” (PID2022-139551NB-I00), funded by the Spanish State Research Agency. Before that, from March to September 2026, I completed an extracurricular internship at SATEC in Cloud & Systems N2, where I worked on support requests, incidents, infrastructure operation, virtual machines, vulnerability patching, SSH/RDP access, and tools such as Nagios, AMS, Veeam Backup, NetApp, and VMware.", + "bio2Upcoming": "On October 8, 2026, I join the URJC research project “Dependencies in complex collections of software modules” (PID2022-139551NB-I00), funded by the Spanish State Research Agency, as a Specialist Technician, combining it with the master's degree at UPM. Until September 2026, I completed an extracurricular internship at SATEC in Cloud & Systems N2, where I worked on support requests, incidents, infrastructure operation, virtual machines, vulnerability patching, SSH/RDP access, and tools such as Nagios, AMS, Veeam Backup, NetApp, and VMware.", "stats": { "experience": "Experience", - "experienceValue": "SATEC Cloud & Systems N2, VBGroup Full-Stack internship, Code-XR, VISSOFT 2025", + "experienceValue": "URJC research (Specialist Technician), SATEC Cloud & Systems N2, VBGroup Full-Stack internship, Code-XR, VISSOFT 2025", + "experienceValueUpcoming": "SATEC Cloud & Systems N2, VBGroup Full-Stack internship, Code-XR, VISSOFT 2025", "education": "Education", "educationValue": "Telematics Engineering (URJC, Jan 2026) · Master's in Telecommunications Engineering at UPM, in progress since Sep 2026", "focus": "Current Focus", @@ -107,6 +117,8 @@ "operatingKicker": "Operating profile", "bio3": "Before that, during my curricular internship at VBGroup, I worked as a Junior Full-Stack Developer on OBT, an internal platform built with Express.js, TypeScript, Cosmos DB, Azure Blob Storage, React, Vite, and Tailwind. I am now enrolled in the Master's Degree in Telecommunications Engineering at UPM, following the Machine Learning and Big Data specialization.", "pills": { + "research": "URJC research · Specialist Technician", + "researchUpcoming": "Upcoming: URJC research", "satec": "SATEC Cloud & Systems N2", "vbgroup": "VBGroup Full-Stack internship", "upm": "UPM Master's · ML & Big Data", @@ -762,6 +774,8 @@ "hoverHint": "Select an item to view details", "selectedMilestone": "Selected milestone", "ongoing": "Active", + "upcoming": "Upcoming", + "startsOn": "Starts {date}", "viewMore": "View more", "clickYearHint": "Select a year to see monthly details", "legend": { @@ -823,12 +837,17 @@ "satecCloud": { "role": "Systems N2 / Cloud Engineer", "org": "SATEC (Extracurricular Internship)", - "desc": "Extracurricular internship focused on systems, N2 support, cloud environments, infrastructure operation, and professional documentation. Started on March 14, 2026, renewed in September 2026, and currently combined with the master's degree at UPM." + "desc": "Extracurricular internship focused on systems, N2 support, cloud environments, infrastructure operation, and professional documentation. From March 14 to September 28, 2026." }, "masterTelecomUPM": { "role": "Master's Degree in Telecommunications Engineering", "org": "Universidad Politécnica de Madrid (UPM)", - "desc": "Official master's degree at UPM, started in September 2026, following the Machine Learning and Big Data specialization within Telecommunications Engineering. Combined with the extracurricular Cloud & Systems N2 internship at SATEC." + "desc": "Official master's degree at UPM, started in September 2026, following the Machine Learning and Big Data specialization within Telecommunications Engineering. Combined with a Specialist Technician position in a URJC research project from October 2026." + }, + "urjcResearch": { + "role": "Specialist Technician (Research)", + "org": "Universidad Rey Juan Carlos (URJC)", + "desc": "Specialist Technician in the research project “Dependencies in complex collections of software modules” (PID2022-139551NB-I00), funded by the Spanish State Research Agency (AEI) and led by Jesús M. González Barahona and Francisco Gortázar. From October 8, 2026, combined with the master's degree at UPM." } }, "kicker": "Career stream", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index bef884b..ae23b07 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -29,11 +29,14 @@ "name": "Adrián Montes Linares", "headline": "Ingeniero Telemático · Estudiante del Máster en Ingeniería de Telecomunicación", "headlineSub": "Sistemas, Cloud, Full-Stack y herramientas XR", - "aboutMe": "Ingeniero Telemático por la URJC, titulado en enero de 2026, con experiencia en desarrollo software, sistemas y visualización de datos. Autor de Code-XR, extensión open source para VS Code publicada en IEEE VISSOFT 2025, orientada al análisis visual de métricas de código en entornos XR. Actualmente cursando el Máster Universitario en Ingeniería de Telecomunicación en la UPM, con intensificación en Aprendizaje Automático y Big Data, compaginándolo con prácticas extracurriculares en Cloud & Sistemas N2 de SATEC, con interés en analítica de software, repositorios de código e investigación aplicada.", + "aboutMe": "Ingeniero Telemático por la URJC, titulado en enero de 2026, con experiencia en desarrollo software, sistemas y visualización de datos. Autor de Code-XR, extensión open source para VS Code publicada en IEEE VISSOFT 2025, orientada al análisis visual de métricas de código en entornos XR. Actualmente cursando el Máster Universitario en Ingeniería de Telecomunicación en la UPM, con intensificación en Aprendizaje Automático y Big Data, compaginándolo con un puesto de Técnico Especialista en un proyecto de investigación de la URJC sobre dependencias en colecciones complejas de módulos software, tras unas prácticas extracurriculares en Cloud & Sistemas N2 de SATEC. Interesado en analítica de software, repositorios de código e investigación aplicada.", + "aboutMeUpcoming": "Ingeniero Telemático por la URJC, titulado en enero de 2026, con experiencia en desarrollo software, sistemas y visualización de datos. Autor de Code-XR, extensión open source para VS Code publicada en IEEE VISSOFT 2025, orientada al análisis visual de métricas de código en entornos XR. Actualmente cursando el Máster Universitario en Ingeniería de Telecomunicación en la UPM, con intensificación en Aprendizaje Automático y Big Data. Tras unas prácticas extracurriculares en Cloud & Sistemas N2 de SATEC, se incorpora el 8 de octubre de 2026 como Técnico Especialista a un proyecto de investigación de la URJC sobre dependencias en colecciones complejas de módulos software. Interesado en analítica de software, repositorios de código e investigación aplicada.", "ctaProjects": "Ver Proyectos", "ctaResume": "Descargar CV", "availabilityLabel": "Actualmente activo", - "availabilityText": "Prácticas en SATEC, desde mar 2026", + "availabilityLabelUpcoming": "Próxima incorporación", + "availabilityText": "Investigación en la URJC, desde oct 2026", + "availabilityTextUpcoming": "Investigación en la URJC, a partir del 8 oct", "location": "Madrid, ES", "featuredProject": { "badge": "Destacado", @@ -63,8 +66,13 @@ "metrics": { "current": { "label": "Actual", - "value": "Cloud & Sistemas N2", - "detail": "SATEC · desde mar 2026" + "value": "Técnico Especialista", + "detail": "Investigación URJC · desde oct 2026" + }, + "upcoming": { + "label": "Próximamente", + "value": "Técnico Especialista", + "detail": "Investigación URJC · a partir del 8 oct 2026" }, "research": { "label": "Investigación", @@ -90,10 +98,12 @@ "name": "Adrián Montes Linares", "role": "Ingeniero Telemático y estudiante del Máster en Ingeniería de Telecomunicación (UPM), con experiencia en Cloud, Sistemas N2, desarrollo Full-Stack y visualización XR de software", "bio1": "Soy Ingeniero Telemático por la Universidad Rey Juan Carlos, titulado en enero de 2026. Mi perfil combina desarrollo software, sistemas, operación cloud y visualización de datos, con un enfoque práctico en herramientas mantenibles y documentación clara.", - "bio2": "Actualmente compagino el máster en la UPM con prácticas extracurriculares en SATEC dentro de Cloud & Sistemas N2, donde trabajo con requests, incidencias, operación de infraestructura, máquinas virtuales, parcheo de vulnerabilidades, acceso SSH/RDP y herramientas como Nagios, AMS, Veeam Backup, NetApp y VMware.", + "bio2": "Actualmente compagino el máster en la UPM con un puesto de Técnico Especialista en el proyecto de investigación de la URJC «Dependencias en colecciones complejas de módulos software» (PID2022-139551NB-I00), financiado por la Agencia Estatal de Investigación. Antes, de marzo a septiembre de 2026, hice prácticas extracurriculares en SATEC dentro de Cloud & Sistemas N2, donde trabajé con requests, incidencias, operación de infraestructura, máquinas virtuales, parcheo de vulnerabilidades, acceso SSH/RDP y herramientas como Nagios, AMS, Veeam Backup, NetApp y VMware.", + "bio2Upcoming": "El 8 de octubre de 2026 me incorporo como Técnico Especialista al proyecto de investigación de la URJC «Dependencias en colecciones complejas de módulos software» (PID2022-139551NB-I00), financiado por la Agencia Estatal de Investigación, y lo compaginaré con el máster en la UPM. Hasta septiembre de 2026 hice prácticas extracurriculares en SATEC dentro de Cloud & Sistemas N2, donde trabajé con requests, incidencias, operación de infraestructura, máquinas virtuales, parcheo de vulnerabilidades, acceso SSH/RDP y herramientas como Nagios, AMS, Veeam Backup, NetApp y VMware.", "stats": { "experience": "Experiencia", - "experienceValue": "SATEC Cloud & Sistemas N2, prácticas Full-Stack en VBGroup, Code-XR, VISSOFT 2025", + "experienceValue": "Investigación en la URJC (Técnico Especialista), SATEC Cloud & Sistemas N2, prácticas Full-Stack en VBGroup, Code-XR, VISSOFT 2025", + "experienceValueUpcoming": "SATEC Cloud & Sistemas N2, prácticas Full-Stack en VBGroup, Code-XR, VISSOFT 2025", "education": "Formación", "educationValue": "Ingeniería Telemática (URJC, ene 2026) · Máster en Ingeniería de Telecomunicación en la UPM, en curso desde sep 2026", "focus": "Enfoque actual", @@ -107,6 +117,8 @@ "operatingKicker": "Perfil operativo", "bio3": "Antes, durante mis prácticas curriculares en VBGroup, trabajé como Desarrollador Full-Stack Junior en OBT, una plataforma interna construida con Express.js, TypeScript, Cosmos DB, Azure Blob Storage, React, Vite y Tailwind. En paralelo curso el Máster Universitario en Ingeniería de Telecomunicación en la UPM, con intensificación en Aprendizaje Automático y Big Data.", "pills": { + "research": "Investigación URJC · Técnico Especialista", + "researchUpcoming": "Próximamente: investigación URJC", "satec": "SATEC Cloud & Sistemas N2", "vbgroup": "Prácticas Full-Stack en VBGroup", "upm": "Máster UPM · ML y Big Data", @@ -762,6 +774,8 @@ "hoverHint": "Selecciona un elemento para ver detalles", "selectedMilestone": "Hito seleccionado", "ongoing": "Activo", + "upcoming": "Próximamente", + "startsOn": "Comienza el {date}", "viewMore": "Ver más", "clickYearHint": "Selecciona un año para ver el detalle mensual", "legend": { @@ -823,12 +837,17 @@ "satecCloud": { "role": "Ingeniero de Sistemas N2 / Cloud", "org": "SATEC (Prácticas extracurriculares)", - "desc": "Prácticas extracurriculares centradas en sistemas, soporte N2, entornos cloud, operación de infraestructura y documentación profesional. Comenzaron el 14 de marzo de 2026, se renovaron en septiembre de 2026 y actualmente las compagino con el máster en la UPM." + "desc": "Prácticas extracurriculares centradas en sistemas, soporte N2, entornos cloud, operación de infraestructura y documentación profesional. Del 14 de marzo al 28 de septiembre de 2026." }, "masterTelecomUPM": { "role": "Máster Universitario en Ingeniería de Telecomunicación", "org": "Universidad Politécnica de Madrid (UPM)", - "desc": "Máster oficial en la UPM, iniciado en septiembre de 2026, con intensificación en Aprendizaje Automático y Big Data dentro de Ingeniería de Telecomunicación. Lo compagino con las prácticas extracurriculares en Cloud & Sistemas N2 de SATEC." + "desc": "Máster oficial en la UPM, iniciado en septiembre de 2026, con intensificación en Aprendizaje Automático y Big Data dentro de Ingeniería de Telecomunicación. Lo compagino con un puesto de Técnico Especialista en un proyecto de investigación de la URJC a partir de octubre de 2026." + }, + "urjcResearch": { + "role": "Técnico Especialista (Investigación)", + "org": "Universidad Rey Juan Carlos (URJC)", + "desc": "Técnico Especialista en el proyecto de investigación «Dependencias en colecciones complejas de módulos software» (PID2022-139551NB-I00), financiado por la Agencia Estatal de Investigación (AEI) y dirigido por Jesús M. González Barahona y Francisco Gortázar. A partir del 8 de octubre de 2026, compaginado con el máster en la UPM." } }, "kicker": "Trayectoria técnica", diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 903ae78..0d127b8 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import { LINKS, SITE } from '@/lib/constants' import { localizePath, type Locale } from '@/i18n' import { getLocalizedProjectTitle, type Project } from '@/features/projects' +import { isUpcomingEntry } from '@/features/journey/timelineMath' +import { BUILD_DATE, CURRENT_ROLE_ID, JOURNEY_ENTRIES } from '@/content/journey' import enMessages from '@/i18n/messages/en.json' import esMessages from '@/i18n/messages/es.json' @@ -11,7 +13,21 @@ const messages = { } as const export const OG_IMAGE = '/images/og/portfolio.png' -export const LAST_MODIFIED = '2026-09-07' +export const LAST_MODIFIED = '2026-10-05' + +const PERSON_SUMMARY = + "Telematics Engineer (URJC, 2026) and Master's student in Telecommunications Engineering at UPM, specializing in Machine Learning and Big Data. Author of Code-XR, an open-source VS Code extension for XR software visualization published at IEEE VISSOFT 2025." + +/** Person description for the build date: the daily rebuild flips it once the current role starts */ +function getPersonDescription(): string { + const currentRole = JOURNEY_ENTRIES.find((entry) => entry.id === CURRENT_ROLE_ID) + + if (currentRole && isUpcomingEntry(currentRole, BUILD_DATE)) { + return `${PERSON_SUMMARY} Former Cloud & Systems N2 intern at SATEC, joining a URJC research project on dependencies in complex collections of software modules as a Specialist Technician in October 2026.` + } + + return `${PERSON_SUMMARY} Specialist Technician in a URJC research project on dependencies in complex collections of software modules; former Cloud & Systems N2 intern at SATEC.` +} const localeMeta = { en: { @@ -178,8 +194,7 @@ export function buildHomeJsonLd(locale: Locale): Record { givenName: 'Adrián', familyName: 'Montes Linares', jobTitle: ['Telematics Engineer', 'Software Engineer'], - description: - "Telematics Engineer (URJC, 2026) and Master's student in Telecommunications Engineering at UPM, specializing in Machine Learning and Big Data. Author of Code-XR, an open-source VS Code extension for XR software visualization published at IEEE VISSOFT 2025. Cloud & Systems N2 intern at SATEC.", + description: getPersonDescription(), url: SITE.url, email: LINKS.email, image: absoluteUrl('/images/profile/hero-320.jpg'), From 422de05124837f10570e8eecbb78f5a8e0ca46f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Montes=20Linares?= <128893712+aMonteSl@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:25:52 +0200 Subject: [PATCH 2/2] feat(ui): polish visuals, motion and support across devices (#9) Typography and layout - Load Inter through next/font: the CSS asked for it but never loaded it, so every OS fell back to its own system font. - Hero: CTAs before the long bio so they sit above the fold on 1366x768 and 1024x768 laptops; the bio is left-aligned on phones; balanced headings. - Section headers share the max-w-6xl content column (no 32px offset at >=1600px); featured project media aligns to the top of its card. - Skill cards: "View group" pinned to the bottom, container-query header so narrow cards keep their titles on one or two lines. - Journey: the "today" label no longer covers the next year's label. - Sidebar fits 1366x768 screens (smaller portrait and rows when short). Motion - MotionConfig reducedMotion="user" app-wide and no more `animate ? fadeInUp() : {}` spreads, which left elements stuck at opacity 0 for reduced-motion visitors after hydration. - Hero entrance is pure CSS, so the first screen paints before hydration; the preload now targets the portrait actually rendered (hero-320). - Softer, longer scroll-in entrances; real staggers instead of dead staggerChildren blocks; no duplicate entrance on secondary projects. - Journey timeline draws in when it scrolls into view; infinite pulses are gated (in view, horizontal layout, reduced motion); the ongoing node breathes instead of blinking. - No `transition-all` on framer-animated elements; non-clickable cards no longer lift on hover. Performance - Hero featured rotation drives its progress bar with a MotionValue and pauses off-screen (it re-rendered the Hero every frame, forever). - Silk background: dpr 1, no MSAA, low-power GPU, ~30fps on-demand loop, reactive reduced-motion check, transparent fallback (no gradient pop-in). - Drop the stale hand-written @react-three/fiber type shims. Devices - Notch-aware page gutter (px-gutter) for viewport-fit=cover, safe-area insets on header, drawer and floating menu button. - Footer clears the floating menu button below xl; 44px touch targets on footer links, DOI chip and tablet header nav; tech tags at 11px. - Base CSS moved to @layer base: one focus ring per element instead of outline + ring with 2px corners; ~150 lines of dead utilities removed. - Manifest theme/background colour matches the site (#040304). Co-authored-by: Claude Opus 5.5 --- public/favicons/site.webmanifest | 4 +- src/app/es/page.tsx | 4 +- src/app/globals.css | 275 +++++------------- src/app/layout.tsx | 17 +- src/app/page.tsx | 4 +- src/components/common/Footer.tsx | 4 +- src/components/common/LocalizedShell.tsx | 18 +- src/components/ui/Container.tsx | 2 +- src/components/ui/SectionHeading.tsx | 7 +- src/components/ui/layout.tsx | 7 +- .../certifications/CertificationsSection.tsx | 23 +- src/features/contact/ContactCTASection.tsx | 13 +- .../journey/ParallelStreamsSection.tsx | 74 +++-- src/features/journey/StreamCard.tsx | 10 +- src/features/journey/StreamLegend.tsx | 3 +- src/features/landing/AnimatedBackground.tsx | 49 ++-- src/features/landing/FeaturedProjectCard.tsx | 55 +++- src/features/landing/Hero.tsx | 54 ++-- src/features/landing/Silk.tsx | 70 ++++- src/features/landing/useFeaturedRotation.ts | 133 +++++---- src/features/morphNav/MorphDrawer.tsx | 6 +- src/features/morphNav/MorphHeader.tsx | 8 +- src/features/morphNav/SidebarPanel.tsx | 5 +- src/features/morphNav/layout.ts | 3 + .../projects/FeaturedProjectsSection.tsx | 14 +- .../components/FeaturedProjectCard.tsx | 11 +- .../components/SecondaryProjectCard.tsx | 5 +- src/features/projects/components/TechTag.tsx | 2 +- .../skills/components/BentoCategoryCard.tsx | 22 +- .../testimonials/TestimonialsSection.tsx | 28 +- src/features/uses/UsesPageClient.tsx | 7 +- src/lib/motion.ts | 86 +----- src/types/fiber.d.ts | 31 -- src/types/three-fiber.d.ts | 10 - 34 files changed, 429 insertions(+), 635 deletions(-) delete mode 100644 src/types/fiber.d.ts delete mode 100644 src/types/three-fiber.d.ts diff --git a/public/favicons/site.webmanifest b/public/favicons/site.webmanifest index d01c939..857c663 100644 --- a/public/favicons/site.webmanifest +++ b/public/favicons/site.webmanifest @@ -15,7 +15,7 @@ "purpose": "maskable" } ], - "theme_color": "#403e3e", - "background_color": "#403e3e", + "theme_color": "#040304", + "background_color": "#040304", "display": "standalone" } \ No newline at end of file diff --git a/src/app/es/page.tsx b/src/app/es/page.tsx index 80f70ab..8df9bb7 100644 --- a/src/app/es/page.tsx +++ b/src/app/es/page.tsx @@ -1,14 +1,14 @@ import type { Metadata } from 'next' import { JsonLd, LocalizedShell } from '@/components/common' import { HomePageClient } from '@/features/landing' -import { SIDEBAR_CONTENT_OFFSET_CLASS } from '@/features/morphNav/layout' +import { FLOATING_MENU_CLEARANCE_CLASS, SIDEBAR_CONTENT_OFFSET_CLASS } from '@/features/morphNav/layout' import { buildHomeJsonLd, buildHomeMetadata } from '@/lib/seo' export const metadata: Metadata = buildHomeMetadata('es') export default function SpanishHome() { return ( - + diff --git a/src/app/globals.css b/src/app/globals.css index 7777052..35207dc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,6 +5,25 @@ --breakpoint-xs: 26.25rem; } +/* + * Page gutter (16/24/32px) that also clears landscape notches: the viewport uses + * viewport-fit=cover, so content would otherwise slide under the notch. + */ +@utility px-gutter { + padding-left: max(1rem, env(safe-area-inset-left)); + padding-right: max(1rem, env(safe-area-inset-right)); + + @media (width >= 40rem) { + padding-left: max(1.5rem, env(safe-area-inset-left)); + padding-right: max(1.5rem, env(safe-area-inset-right)); + } + + @media (width >= 64rem) { + padding-left: max(2rem, env(safe-area-inset-left)); + padding-right: max(2rem, env(safe-area-inset-right)); + } +} + /* CSS Variables for theming */ :root { --bg: #040304; @@ -43,7 +62,7 @@ body { radial-gradient(circle at 70% 8%, rgba(166, 107, 87, 0.12), transparent 32rem), linear-gradient(180deg, #060403 0%, var(--bg) 36%, #070504 100%); color: var(--fg); - font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + font-family: var(--font-inter), Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; line-height: 1.6; letter-spacing: 0.01em; position: relative; @@ -96,11 +115,37 @@ body::before { border-bottom: 1px solid; } -/* Focus styles for accessibility */ -*:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: 2px; +/* + * Base rules live in the base layer so Tailwind utilities can override them + * (e.g. `focus-visible:outline-none` + a ring, or an explicit image height). + */ +@layer base { + /* Default focus ring for anything that does not draw its own */ + *:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + ::selection { + background-color: rgba(220, 162, 147, 0.3); + color: var(--fg); + } + + img { + image-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + max-width: 100%; + height: auto; + } + + /* Photos, portraits and carousel frames fill their aspect box */ + img[class*="object-cover"], + img[class*="object-contain"] { + display: block; + width: 100%; + height: 100%; + } } /* Skip to content link */ @@ -122,91 +167,6 @@ body::before { top: 6px; } -/* Utility classes */ -.container { - width: 100%; - max-width: 1200px; - margin: 0 auto; - padding: 0 1rem; -} - -@media (min-width: 640px) { - .container { - padding: 0 2rem; - } -} - -.section { - padding: 4rem 0; -} - -@media (min-width: 768px) { - .section { - padding: 6rem 0; - } -} - -/* Selection styling */ -::selection { - background-color: rgba(220, 162, 147, 0.3); - color: var(--fg); -} - -/* Image quality optimization - Prevent blurry images across entire project */ -img { - /* High quality rendering for all resolutions */ - image-rendering: -webkit-optimize-contrast; - image-rendering: auto; - - /* Smooth font rendering */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - /* Prevent layout shifts */ - max-width: 100%; - height: auto; - - /* Disable blur filter by default */ - filter: none; -} - -/* For images that need smooth scaling (photos, portraits, carousels) */ -img[class*="object-cover"], -img[class*="object-contain"], -motion img, -[data-framer-component-type="motion.img"] img { - image-rendering: auto; - - /* Ensure proper scaling */ - display: block; - width: 100%; - height: 100%; - - /* Smooth transitions - prevents flashing */ - will-change: opacity; - backface-visibility: hidden; - -webkit-backface-visibility: hidden; -} - -/* Avatar images - extra sharp but smooth */ -.avatar img, -[class*="Avatar"] img { - image-rendering: -webkit-optimize-contrast; - image-rendering: auto; -} - -/* Carousel and gallery images - optimized for smooth viewing and transitions */ -.carousel img, -.gallery img, -[class*="Carousel"] img, -[class*="Gallery"] img { - image-rendering: auto; - -webkit-font-smoothing: antialiased; - will-change: opacity; - backface-visibility: hidden; - -webkit-backface-visibility: hidden; -} - .photo-render { image-rendering: auto; filter: none; @@ -227,129 +187,36 @@ motion img, } } -/* Animation utility classes */ -.transition-base { - transition: all var(--dur-base) var(--ease); -} - -.transition-fast { - transition: all var(--dur-fast) var(--ease-out); -} - -/* Hover effects for interactive elements */ -.hover-lift { - transition: transform var(--dur-fast) var(--ease-out); -} - -.hover-lift:hover { - transform: translateY(-2px); -} - -/* Shine effect for avatar */ -.shine-on-hover { - position: relative; - overflow: hidden; -} - -.shine-on-hover::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 45deg, - transparent 30%, - rgba(255, 255, 255, 0.1) 50%, - transparent 70% - ); - transform: translateX(-100%); - transition: transform var(--dur-slow) var(--ease); -} - -.shine-on-hover:hover::after { - transform: translateX(100%); -} - -/* Reduce animations for accessibility */ -@media (prefers-reduced-motion: reduce) { - .scroll-cue { - animation: none; - } - - .transition-base, - .transition-fast, - .hover-lift, - .shine-on-hover::after { - transition: none; - } - - .hover-lift:hover { - transform: none; - } - - .shine-on-hover:hover::after { - transform: translateX(-100%); - } -} - -/* Fluid container utilities */ -.content-width { - max-width: 72rem; -} - -@media (min-width: 1536px) { - .content-width { - max-width: 80rem; +/* + * Hero entrance: plain CSS so the first screen paints (and animates) before + * JavaScript hydrates. Stagger with an arbitrary `[animation-delay:…]` utility. + */ +@layer components { + .hero-enter { + animation: hero-enter 0.7s var(--ease-out) both; } } -@media (min-width: 1920px) { - .content-width { - max-width: 88rem; +@keyframes hero-enter { + from { + opacity: 0; + transform: translateY(14px); } -} -@media (min-width: 2560px) { - .content-width { - max-width: 96rem; + to { + opacity: 1; + transform: none; } } -/* Fluid spacing utilities */ -.section-y { - padding-block: clamp(2rem, 4vw + 1rem, 6rem); -} - -.section-x { - padding-inline: clamp(1rem, 3vw, 3rem); -} - -/* Header scroll effects */ -.header { - transition: box-shadow 180ms var(--ease), backdrop-filter 180ms var(--ease); -} - -.header.has-shadow { - box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.45); - backdrop-filter: saturate(1.1) blur(4px); -} - -/* Hero spotlight effect */ -.hero-spotlight::before { - content: ""; - position: absolute; - inset: -10% -10% -10% -10%; - background: radial-gradient(60% 60% at 50% 35%, rgba(220, 162, 147, 0.12), transparent 70%); - pointer-events: none; - z-index: -1; -} - -/* Reduce motion accessibility */ +/* Reduce animations for accessibility */ @media (prefers-reduced-motion: reduce) { - .header { - transition: none; - } - html { scroll-behavior: auto; } + + .scroll-cue, + .hero-enter { + animation: none; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8c2d3a..73eb992 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from 'next' +import { Inter } from 'next/font/google' import './globals.css' import { SITE } from '@/lib/constants' import { OG_IMAGE } from '@/lib/seo' @@ -58,6 +59,13 @@ export const metadata: Metadata = { }, } +// Self-hosted at build time so every device renders the same typeface (no system-font fallback) +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}) + export const viewport: Viewport = { width: 'device-width', initialScale: 1, @@ -71,14 +79,15 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - + + {/* The hero portrait (Avatar size="hero") is the largest image on the first screen */} diff --git a/src/app/page.tsx b/src/app/page.tsx index 28589ee..0ca2e0a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,14 +1,14 @@ import type { Metadata } from 'next' import { JsonLd, LocalizedShell } from '@/components/common' import { HomePageClient } from '@/features/landing' -import { SIDEBAR_CONTENT_OFFSET_CLASS } from '@/features/morphNav/layout' +import { FLOATING_MENU_CLEARANCE_CLASS, SIDEBAR_CONTENT_OFFSET_CLASS } from '@/features/morphNav/layout' import { buildHomeJsonLd, buildHomeMetadata } from '@/lib/seo' export const metadata: Metadata = buildHomeMetadata('en') export default function Home() { return ( - + diff --git a/src/components/common/Footer.tsx b/src/components/common/Footer.tsx index 0f5b867..2e9518d 100644 --- a/src/components/common/Footer.tsx +++ b/src/components/common/Footer.tsx @@ -66,7 +66,7 @@ export function Footer({ className }: FooterProps = {}) {
  • {link.label} @@ -86,7 +86,7 @@ export function Footer({ className }: FooterProps = {}) { href={link.href} target={link.external ? '_blank' : undefined} rel={link.external ? 'noopener noreferrer' : undefined} - className="inline-flex min-h-9 max-w-full items-center gap-1.5 py-1 text-sm text-[var(--fg-muted)] transition-colors duration-200 hover:text-[var(--fg)]" + className="inline-flex min-h-9 max-w-full items-center gap-1.5 py-1 text-sm pointer-coarse:min-h-11 text-[var(--fg-muted)] transition-colors duration-200 hover:text-[var(--fg)]" > {link.label} {link.external && ( diff --git a/src/components/common/LocalizedShell.tsx b/src/components/common/LocalizedShell.tsx index 5419f5b..fc42560 100644 --- a/src/components/common/LocalizedShell.tsx +++ b/src/components/common/LocalizedShell.tsx @@ -1,6 +1,7 @@ 'use client' import type { ReactNode } from 'react' +import { MotionConfig } from 'framer-motion' import { Footer } from '@/components/common/Footer' import { I18nProvider, useTranslations, type Locale } from '@/i18n' import { BrowserTranslateHint, LocalePreferenceGate } from '@/features/language' @@ -31,13 +32,16 @@ export function LocalizedShell({ }: LocalizedShellProps) { return ( - - -
    - {children} -
    - {showFooter &&