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/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/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/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 &&