Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/components/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,30 +30,30 @@ export const LI: React.FC<React.LiHTMLAttributes<HTMLLIElement>> = (props) => (
// title is: fixed sizes here meant an h2 that matched the title at 640px and
// overtook it below that, so the deeper heading looked like the page heading.
export const H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({ className, ...props }) => (
<h1 {...props} className={className || "text-2xl sm:text-3xl md:text-4xl font-semibold mt-8 mb-4 scroll-mt-12"}/>
<h1 {...props} className={className || "text-2xl sm:text-3xl md:text-4xl font-semibold mt-8 mb-4"}/>
);

export const H2: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h2 {...props} className="text-xl sm:text-2xl md:text-3xl font-semibold mt-12 mb-4 scroll-mt-12"/>
<h2 {...props} className="text-xl sm:text-2xl md:text-3xl font-semibold mt-12 mb-4"/>
);

export const H3: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h3 {...props} className="text-lg sm:text-xl md:text-2xl font-semibold mt-8 mb-3 scroll-mt-12"/>
<h3 {...props} className="text-lg sm:text-xl md:text-2xl font-semibold mt-8 mb-3"/>
);

export const H4: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h4 {...props} className="text-lg md:text-xl font-semibold mt-6 mb-2 scroll-mt-12"/>
<h4 {...props} className="text-lg md:text-xl font-semibold mt-6 mb-2"/>
);

// h5 and h6 land on the body size rather than below it. A heading set smaller
// than the text it introduces reads as a caption; weight and the display face
// carry the distinction at this depth instead.
export const H5: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h5 {...props} className="text-lg font-bold mt-6 mb-2 scroll-mt-12"/>
<h5 {...props} className="text-lg font-bold mt-6 mb-2"/>
);

export const H6: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h6 {...props} className="text-lg font-semibold mt-6 mb-2 scroll-mt-12"/>
<h6 {...props} className="text-lg font-semibold mt-6 mb-2"/>
);

export const P: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = (props) => (
Expand Down
2 changes: 1 addition & 1 deletion src/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const Footer = () => {
>
ELIXIR
</a>
{' '} the European infrastructure for life science data.
{' '}&ndash; the European infrastructure for life science data.
</p>
<div className="flex gap-3">
{socials.map(({ name, href, icon: Icon }) => (
Expand Down
38 changes: 20 additions & 18 deletions src/components/hero.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,42 @@
import { MotionConfig, motion, useReducedMotion } from 'framer-motion';
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from 'framer-motion';
import React, { useState, useEffect } from 'react';
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
import { PauseIcon, PlayIcon } from '@heroicons/react/20/solid';
import Button from './button';
import HeroVideo from './hero-video';
import type { HeroCopy } from '../lib/site';

const FLIP_MS = 600;
const SWAP = { duration: 0.5, ease: [0.22, 1, 0.36, 1] } as const;
// Multi-word phrases stay a little longer than single fields so they can be read.
const dwellMs = (word: string, words: string[]) => (word.includes(' ') && word !== words[0] ? 2600 : 2300);

function RotatingWord({ words, playing }: { words: string[]; playing: boolean }) {
const [{ current, leaving }, setFlip] = useState<{ current: number; leaving: number | null }>({ current: 0, leaving: null });
const [current, setCurrent] = useState(0);

useEffect(() => {
if (!playing) return;
const id = setTimeout(() => {
setFlip(({ current }) => ({ current: (current + 1) % words.length, leaving: current }));
}, FLIP_MS + dwellMs(words[current], words));
const id = setTimeout(() => setCurrent((i) => (i + 1) % words.length), SWAP.duration * 1000 + dwellMs(words[current], words));
return () => clearTimeout(id);
}, [playing, current, words]);

useEffect(() => {
if (leaving === null) return;
const id = setTimeout(() => setFlip(flip => ({ ...flip, leaving: null })), FLIP_MS);
return () => clearTimeout(id);
}, [leaving]);

return (
<span className="rotor" aria-hidden="true">
{words.map((word, i) => {
const state = i === current
? (leaving === null ? 'is-on' : 'is-on is-in')
: i === leaving ? 'is-out' : '';
return <span key={word} className={`rotor-word ${state}`}>{word}</span>;
})}
{words.map((word) => <span key={word} className="rotor-word invisible">{word}</span>)}
{/* The old phrase rises out while the new one rises in over it, so the line is never empty mid-swap.
Search picks up every phrase from the invisible copies above, so this one stays out of it. */}
<AnimatePresence initial={false}>
<motion.span
key={words[current]}
className="rotor-word"
data-pagefind-ignore
initial={{ y: '0.5em', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-0.5em', opacity: 0 }}
transition={SWAP}
>
{words[current]}
</motion.span>
</AnimatePresence>
</span>
);
}
Expand Down
16 changes: 10 additions & 6 deletions src/components/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,18 @@ export const Navigation = ({ pathname }: { pathname: string }) => {
return (
<MotionConfig reducedMotion="user">
<CommandPalette open={searchOpen} setOpen={setSearchOpen} />
<header className="pointer-events-none fixed top-3 inset-x-3 sm:inset-x-5 lg:inset-x-8 z-50">
{/* Floats inset from the edges at the top of the page; once merged it becomes a full-width
bar, padded so the logo and actions keep the same place across the edges. Both sit
below the site banner, which stays pinned to the top. */}
<header className={`pointer-events-none fixed z-50 ${scrolled ? 'inset-x-0 top-[var(--site-banner-height,0px)]' : 'top-[calc(var(--site-banner-height,0px)_+_0.75rem)] inset-x-3 sm:inset-x-5 lg:inset-x-8'}`}>
<motion.div
layout
initial={false}
transition={layoutTransition}
style={{ borderRadius: PILL_RADIUS }}
data-merged={scrolled}
className={`group relative grid grid-cols-[1fr_auto_1fr] items-center ${scrolled ? 'pointer-events-auto px-4 py-2 lg:px-5' : ''}`}
className={`group relative grid grid-cols-[1fr_auto_1fr] items-center ${scrolled ? 'pointer-events-auto px-3 py-2 sm:px-5 lg:px-8' : ''}`}
>
<Skin visible={scrolled} radius={PILL_RADIUS} transition={layoutTransition} className="shadow-lg shadow-black/[0.08] dark:shadow-black/30" />
<Skin visible={scrolled} radius={0} transition={layoutTransition} className="shadow-lg shadow-black/[0.08] dark:shadow-black/30" />

<nav aria-label="Main navigation" className="contents">
{/* Logo: floats large on its own at the top, shrinks into the bar once merged */}
Expand Down Expand Up @@ -251,8 +253,10 @@ export const Navigation = ({ pathname }: { pathname: string }) => {
aria-modal="true"
aria-label="Mobile navigation"
>
<nav aria-label="Mobile navigation" className="flex-1 flex flex-col justify-center overflow-y-auto overscroll-contain px-8 sm:px-12 pt-24 pb-4">
<ul className="space-y-1">
{/* Auto margins centre the links while they fit and, unlike justify-center, let them
scroll from the top when they don't, so none is stuck under the header. */}
<nav aria-label="Mobile navigation" className="flex-1 flex flex-col overflow-y-auto overscroll-contain px-8 sm:px-12 pt-[calc(var(--site-banner-height,0px)_+_6rem)] pb-4">
<ul className="my-auto space-y-1">
{navigation.map((item, i) => {
const active = isActivePath(pathname, item.href);
const bigLink = `block py-2.5 landscape:py-1.5 text-2xl landscape:text-xl sm:text-3xl font-semibold tracking-tight text-ink decoration-marker decoration-2 underline-offset-[6px] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:rounded-control ${active ? 'underline' : 'hover:underline'}`;
Expand Down
2 changes: 1 addition & 1 deletion src/components/sections/base-section.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const headingSlug = slugify(title.toLowerCase());
<section class={`${marginTop ? "mt-16 lg:mt-20" : ""}`}>
<div class="mx-auto max-w-6xl">
<h2
class="text-2xl sm:text-3xl font-semibold tracking-tight text-ink scroll-mt-20"
class="text-2xl sm:text-3xl font-semibold tracking-tight text-ink"
id={headingSlug}
>
{title}
Expand Down
3 changes: 2 additions & 1 deletion src/components/sections/latest-news.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import Button from "../button.tsx";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');

const news = (await getCollection("news"))
.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime())
// Same-date entries fall back to their id, so every build picks the same three.
.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime() || a.id.localeCompare(b.id))
.slice(0, 3);

function resolveCoverUrl(articleId: string, coverSource: string): string {
Expand Down
2 changes: 1 addition & 1 deletion src/components/sections/services-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export default function ServicesOverview() {
<div className="grid grid-cols-1 lg:grid-cols-5 gap-12 lg:gap-16 items-start">
{/* Heading column */}
<motion.div
className="lg:col-span-2 lg:sticky lg:top-32"
className="lg:col-span-2 lg:sticky lg:top-[calc(var(--site-banner-height,0px)_+_8rem)]"
initial={{ y: 20 }}
whileInView={{ y: 0 }}
viewport={{ once: true, margin: '-60px' }}
Expand Down
16 changes: 12 additions & 4 deletions src/components/site-banner.astro
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function renderInlineMarkdown(md: string): string {
</svg>
<p set:html={renderInlineMarkdown(message)} />
<button
onclick="document.getElementById('site-banner').remove(); sessionStorage.setItem('banner-dismissed','1')"
onclick="document.getElementById('site-banner').remove(); document.documentElement.style.removeProperty('--site-banner-height'); sessionStorage.setItem('banner-dismissed','1')"
class="fixed right-3 top-1 p-1 rounded-control !text-paper/60 hover:!text-paper hover:bg-paper/10 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper/50 z-[61]"
aria-label="Dismiss announcement"
>
Expand All @@ -51,8 +51,16 @@ function renderInlineMarkdown(md: string): string {
</style>

<script is:inline>
if (sessionStorage.getItem('banner-dismissed') === '1') {
(() => {
const b = document.getElementById('site-banner');
if (b) b.remove();
}
if (!b) return;
if (sessionStorage.getItem('banner-dismissed') === '1') {
b.remove();
return;
}
// The fixed navigation sits below the banner, which stays pinned to the top while scrolling.
const reserve = () => document.documentElement.style.setProperty('--site-banner-height', `${b.offsetHeight}px`);
reserve();
new ResizeObserver(reserve).observe(b);
})();
</script>
2 changes: 1 addition & 1 deletion src/layouts/base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const { seo } = Astro.props;
<!-- Skip to content link for accessibility -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-brand-primary focus:text-white focus:rounded-control focus:outline-none focus:ring-2 focus:ring-brand-primary focus:ring-offset-2"
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[70] focus:px-4 focus:py-2 focus:bg-brand-primary focus:text-white focus:rounded-control focus:outline-none focus:ring-2 focus:ring-brand-primary focus:ring-offset-2"
>
Skip to main content
</a>
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/page.astro
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ const jsonLd = contentType === 'news' ? JSON.stringify({
class="hidden lg:block"
aria-label="Sidebar navigation"
>
<div class="sticky top-24">
<div class="sticky top-[calc(var(--site-banner-height,0px)_+_6rem)]">
<slot name="sidebar" />
</div>
</aside>
Expand Down
4 changes: 2 additions & 2 deletions src/pages/about/everyone.astro
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const externalPeople: ExternalPerson[] = externalOrgKeys.flatMap((key) => {
mainOrgs.map(({ key, data: orgData, meta: orgMeta }) => (
<section
id={`org-${key}`}
class="mb-16 lg:mb-20 last:mb-0 scroll-mt-24"
class="mb-16 lg:mb-20 last:mb-0"
>
<div class="flex items-center gap-4 mb-8">
{orgMeta && (
Expand Down Expand Up @@ -248,7 +248,7 @@ const externalPeople: ExternalPerson[] = externalOrgKeys.flatMap((key) => {
{externalPeople.length > 0 && (
<section
id="org-external"
class="mt-16 lg:mt-20 pt-16 lg:pt-20 border-t border-rule scroll-mt-24"
class="mt-16 lg:mt-20 pt-16 lg:pt-20 border-t border-rule"
>
<div class="mb-8">
<h2 class="text-2xl sm:text-3xl font-semibold text-ink tracking-tight">
Expand Down
3 changes: 2 additions & 1 deletion src/pages/events/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ function parseDate(dateString: string): number {

const now = Date.now();
const events = await getCollection("events");
events.sort((a, b) => parseDate(b.data.date) - parseDate(a.data.date));
// Same-date entries fall back to their id, so every build lists them in the same order.
events.sort((a, b) => parseDate(b.data.date) - parseDate(a.data.date) || a.id.localeCompare(b.id));

const upcoming = events.filter(e => parseDate(e.data.date) >= now);
const past = events.filter(e => parseDate(e.data.date) < now);
Expand Down
4 changes: 3 additions & 1 deletion src/pages/funding-and-projects/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ const statusColors: Record<string, { text: string; dot: string }> = {

<!-- Filter sidebar -->
<aside class="lg:w-60 shrink-0">
<div class="lg:sticky lg:top-28">
<!-- Scrolls on its own when taller than the window, so the last filters can be seen
and focused without reaching the end of the page. The padding keeps focus rings unclipped. -->
<div class="lg:sticky lg:top-[calc(var(--site-banner-height,0px)_+_7rem)] lg:max-h-[calc(100vh_-_var(--site-banner-height,0px)_-_8rem)] lg:overflow-y-auto lg:-m-1 lg:p-1">
<!-- Search -->
<div class="mb-6">
<div class="relative">
Expand Down
3 changes: 2 additions & 1 deletion src/pages/news/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ function formatDate(dateString: string): string {
}

const news = await getCollection('news');
news.sort((a, b) => parseDate(b.data.date) - parseDate(a.data.date));
// Same-date entries fall back to their id, so every build lists them in the same order.
news.sort((a, b) => parseDate(b.data.date) - parseDate(a.data.date) || a.id.localeCompare(b.id));

// Build year list with counts (newest first)
const yearCounts = new Map<string, number>();
Expand Down
3 changes: 2 additions & 1 deletion src/pages/rss.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export async function GET(context: any) {
}));

const items = [...newsItems, ...eventItems]
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
// Same-date items fall back to their link, so every build orders the feed the same way.
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime() || a.link.localeCompare(b.link));

return rss({
title: 'ELIXIR Norway',
Expand Down
24 changes: 2 additions & 22 deletions src/styles/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@

html {
font-size: 16px;
/* In-page links and focus land below the fixed navigation and the site banner. */
scroll-padding-top: calc(var(--site-banner-height, 0px) + var(--nav-offset) + 1rem);

@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
Expand Down Expand Up @@ -125,25 +127,13 @@
.rotor {
display: grid;
place-items: center;
perspective: 6em;
}

.rotor-word {
grid-area: 1 / 1;
visibility: hidden;
max-width: 100%;
text-align: center;
text-wrap: balance;
transform-origin: 50% 50% -0.5em;
backface-visibility: hidden;

&.is-on, &.is-out { visibility: visible; }
&.is-in { animation: rotor-in 0.6s cubic-bezier(0.22, 0.8, 0.26, 1) both; }
&.is-out { animation: rotor-out 0.6s cubic-bezier(0.22, 0.8, 0.26, 1) both; }

@media (prefers-reduced-motion: reduce) {
animation: none !important;
}
}

/* Soft paper halo that keeps hero copy legible over the video without a box behind it. */
Expand All @@ -154,16 +144,6 @@
0 0 30px rgb(var(--color-paper) / 0.55);
}

@keyframes rotor-in {
from { transform: rotateX(90deg); opacity: 0.25; }
to { transform: rotateX(0); opacity: 1; }
}

@keyframes rotor-out {
from { transform: rotateX(0); opacity: 1; }
to { transform: rotateX(-90deg); opacity: 0.25; }
}

.content {
& :first-child {
@apply mt-0;
Expand Down
Loading