Initial commit 🚀

This commit is contained in:
Felix Klement committed 2026-10-09 14:00:40 +02:00
commit fd58a8d32f
235 files changed
+19775

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
---
import { Picture } from 'astro:assets';
import felix from '../../assets/images/people/felix.png';
import florian from '../../assets/images/people/florian.png';
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
// Where each statement's eyebrow leads, and whose portrait sits beside it. Same order as ui.quote.items.
const sources = [
{ href: localePath(lang, '/insights/xg-aloe-user-forum'), portrait: felix },
{ href: `${localePath(lang, '/team')}#research-title`, portrait: florian },
];
const items = ui.quote.items.map((item, i) => ({ ...item, ...sources[i] }));
const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}”`);
---
<section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
<div class="container">
<div class="quote__frame">
<div class="quote__panel">
<div class="quote__stack" aria-live="off" data-live>
{items.map((item, i) => (
<figure
class="quote__slide"
id={`quote-${i}`}
role="group"
aria-roledescription="slide"
aria-label={ui.quote.slide(i + 1, items.length)}
data-slide
data-active={i === 0 ? '' : undefined}
>
<p class="eyebrow"><a href={item.href} class="quote__source">{item.eyebrow}</a></p>
<blockquote>
<p class="h2 quote__text">{quoted(item.text)}</p>
</blockquote>
<figcaption class="quote__caption">
<span class="quote__name">{item.name}</span><br /><span class="muted">{item.role}</span>
</figcaption>
</figure>
))}
</div>
<div class="quote__controls" data-controls hidden>
{items.map((item, i) => (
<button
type="button"
class="quote__pick"
aria-controls={`quote-${i}`}
aria-label={ui.quote.show(item.name)}
aria-current={i === 0 ? 'true' : undefined}
data-pick
>
<span class="quote__track" aria-hidden="true"><span class="quote__fill"></span></span>
</button>
))}
</div>
</div>
<div class="quote__portraits">
{items.map((item, i) => (
<div class="quote__portrait" data-portrait data-active={i === 0 ? '' : undefined}>
<Picture
src={item.portrait}
alt={item.portraitAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[480, 640, 960, 1200]}
sizes="(min-width: 64rem) 40vw, 100vw"
loading="lazy"
decoding="async"
class="img-mono quote__img"
pictureAttributes={{ class: 'quote__picture' }}
/>
</div>
))}
</div>
</div>
</div>
</section>
<style>
.quote {
/* One statement holds for this long before the next one comes in. */
--quote-hold: 9s;
}
.quote__frame {
display: grid;
background: var(--paper-deep);
}
.quote__panel {
display: grid;
gap: var(--space-7);
align-content: space-between;
padding: var(--space-7) var(--space-6);
}
/* Slides share one grid cell, so the frame takes the height of the longest and never jumps. */
.quote__stack, .quote__portraits { display: grid; }
.quote__slide, .quote__portrait { grid-area: 1 / 1; }
/*
* The change: the outgoing statement lifts away, then the incoming one rises in line by line,
* eyebrow, quote, caption. The new portrait fades in over the old one and settles from a slight
* scale, as the hero image does on load.
*/
.quote__slide {
display: grid;
gap: var(--space-7);
align-content: space-between;
margin: 0;
visibility: hidden;
transition: visibility 0s linear var(--dur-slow);
}
.quote__slide > * {
opacity: 0;
transform: translateY(var(--space-6));
transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}
.quote__slide[data-leaving] > * { transform: translateY(calc(-1 * var(--space-5))); }
.quote__slide > :nth-child(2) { --i: 1; }
.quote__slide > :nth-child(3) { --i: 2; }
.quote__slide[data-active] { visibility: visible; transition: visibility 0s; }
.quote__slide[data-active] > * {
opacity: 1;
transform: none;
transition-duration: var(--dur-rise);
transition-delay: calc(var(--dur-fast) + var(--i, 0) * var(--stagger-rise));
}
.quote__portraits { overflow: hidden; }
.quote__portrait {
opacity: 0;
visibility: hidden;
/* The old portrait stays put until the new one has covered it. */
transition: opacity 0s linear var(--dur-hero), visibility 0s linear var(--dur-hero);
}
.quote__portrait :global(img) {
transform: scale(1.06);
transition: transform 0s linear var(--dur-hero);
}
.quote__portrait[data-active] {
z-index: 1;
opacity: 1;
visibility: visible;
transition: opacity var(--dur-rise) var(--ease), visibility 0s;
}
.quote__portrait[data-active] :global(img) {
transform: none;
transition: transform var(--dur-hero) var(--ease);
}
.quote__source { text-decoration: none; }
.quote__source:hover { text-decoration: underline; }
.quote__text { text-wrap: balance; max-width: 16em; }
.quote__name { font-weight: var(--weight-medium); }
/*
* Controls: the progress bar runs along the bottom edge of the text panel, one segment per
* statement, filling while it is shown. Each segment is a button with a taller hit area above it.
*/
.quote__panel { position: relative; }
.quote__controls {
position: absolute;
inset: auto 0 0 0;
z-index: 2; /* above the portrait, which on narrow screens starts right below the bar */
display: flex;
gap: var(--space-1);
}
.quote__controls[hidden] { display: none; }
.quote__pick {
display: flex;
flex-direction: column;
justify-content: end;
flex: 1 1 0;
min-width: 0;
height: var(--space-6);
padding: 0;
background: none;
border: 0;
cursor: pointer;
}
.quote__pick:focus-visible { outline-offset: calc(-1 * var(--focus-width)); }
.quote__pick:hover .quote__track { background: var(--ink-muted); }
.quote__track {
display: block;
height: calc(2 * var(--hairline));
transition: background-color var(--dur-fast) var(--ease);
background: var(--line);
overflow: hidden;
}
.quote__fill {
display: block;
height: 100%;
background: var(--ink);
/* A full-width bar slid in from the left, not a scaled one: translation stays on the
compositor and moves by sub-pixels, scaling from zero re-rasterises and steps. */
transform: translateX(-100%);
will-change: transform;
}
.quote__pick[aria-current] .quote__fill { transform: none; }
.quote[data-playing] .quote__pick[aria-current] .quote__fill {
animation: quote-fill var(--quote-hold) linear both;
}
.quote[data-playing]:is([data-held], [data-away]) .quote__pick[aria-current] .quote__fill { animation-play-state: paused; }
@keyframes quote-fill { from { transform: translateX(-100%); } to { transform: none; } }
:global(.quote__picture) { display: block; width: 100%; height: 100%; }
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
@media (min-width: 64rem) {
.quote__frame { grid-template-columns: 11fr 9fr; }
.quote__panel { padding: var(--space-8) var(--space-8); }
/* The image's own ratio once loaded, and the same square reserved before, so the lazy
portrait does not grow the section when it arrives. Both portraits are square. */
.quote__img, :global(.quote__img) { aspect-ratio: auto 1 / 1; }
}
@media (prefers-reduced-motion: reduce) {
.quote__slide, .quote__slide > *, .quote__portrait, .quote__portrait :global(img) {
transition: none !important;
transform: none !important;
}
}
</style>
<script>
/**
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
* keyboard focus is inside or the section is off screen; picking a statement stops it.
*/
const root = document.querySelector<HTMLElement>('[data-quotes]');
if (root) {
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
const portraits = Array.from(root.querySelectorAll<HTMLElement>('[data-portrait]'));
const picks = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-pick]'));
const live = root.querySelector<HTMLElement>('[data-live]')!;
const controls = root.querySelector<HTMLElement>('[data-controls]')!;
const fills = picks.map((p) => p.querySelector<HTMLElement>('.quote__fill')!);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let index = 0;
let playing = false;
const show = (next: number) => {
next = (next + slides.length) % slides.length;
if (next === index) return;
// The outgoing statement lifts up instead of sinking back to where the incoming one rises from.
const leaving = slides[index];
leaving.setAttribute('data-leaving', '');
window.setTimeout(() => leaving.removeAttribute('data-leaving'), 1000);
index = next;
[slides, portraits].forEach((set) => set.forEach((el, i) => el.toggleAttribute('data-active', i === index)));
picks.forEach((p, i) => (i === index ? p.setAttribute('aria-current', 'true') : p.removeAttribute('aria-current')));
};
const setPlaying = (on: boolean) => {
playing = on;
root.toggleAttribute('data-playing', on);
live.setAttribute('aria-live', on ? 'off' : 'polite');
};
// The fill on the current pick is the timer: when its animation ends, the next statement comes.
fills.forEach((fill) => fill.addEventListener('animationend', () => { if (playing) show(index + 1); }));
picks.forEach((pick, i) => pick.addEventListener('click', () => { setPlaying(false); show(i); }));
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
root.addEventListener('focusin', () => hold(true));
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
// Off screen, the clock stops, so a reader arriving from above starts on a fresh statement.
new IntersectionObserver(([entry]) => root.toggleAttribute('data-away', !entry.isIntersecting), { threshold: 0.4 }).observe(root);
controls.hidden = false;
setPlaying(!reduced);
}
</script>