asdf
This commit is contained in:
1 parent
efc2ad7da4
commit
3e6cfcbe35
2 files changed
+274
-40
No files matched your search
+236
-28
@@ -1,44 +1,87 @@
|
|||||||
---
|
---
|
||||||
import { Picture } from 'astro:assets';
|
import { Picture } from 'astro:assets';
|
||||||
import felix from '../../assets/images/people/felix.png';
|
import felix from '../../assets/images/people/felix.png';
|
||||||
|
import florian from '../../assets/images/people/florian.png';
|
||||||
import { localePath, t, type Lang } from '../../i18n/ui';
|
import { localePath, t, type Lang } from '../../i18n/ui';
|
||||||
interface Props { lang: Lang }
|
interface Props { lang: Lang }
|
||||||
const { lang } = Astro.props;
|
const { lang } = Astro.props;
|
||||||
const ui = t(lang);
|
const ui = t(lang);
|
||||||
const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
// 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-labelledby="quote-text">
|
<section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<figure class="quote__figure">
|
<div class="quote__frame">
|
||||||
<div class="quote__panel">
|
<div class="quote__panel">
|
||||||
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p>
|
<div class="quote__stack" aria-live="off" data-live>
|
||||||
<blockquote>
|
{items.map((item, i) => (
|
||||||
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p>
|
<figure
|
||||||
</blockquote>
|
class="quote__slide"
|
||||||
<figcaption class="quote__caption">
|
id={`quote-${i}`}
|
||||||
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p>
|
role="group"
|
||||||
</figcaption>
|
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>
|
||||||
<div class="quote__portrait">
|
<div class="quote__portraits">
|
||||||
<Picture
|
{items.map((item, i) => (
|
||||||
src={felix}
|
<div class="quote__portrait" data-portrait data-active={i === 0 ? '' : undefined}>
|
||||||
alt={ui.quote.portraitAlt}
|
<Picture
|
||||||
formats={['avif']}
|
src={item.portrait}
|
||||||
fallbackFormat="webp"
|
alt={item.portraitAlt}
|
||||||
widths={[480, 640, 960, 1200]}
|
formats={['avif']}
|
||||||
sizes="(min-width: 64rem) 40vw, 100vw"
|
fallbackFormat="webp"
|
||||||
loading="lazy"
|
widths={[480, 640, 960, 1200]}
|
||||||
decoding="async"
|
sizes="(min-width: 64rem) 40vw, 100vw"
|
||||||
class="img-mono quote__img"
|
loading="lazy"
|
||||||
pictureAttributes={{ class: 'quote__picture' }}
|
decoding="async"
|
||||||
/>
|
class="img-mono quote__img"
|
||||||
|
pictureAttributes={{ class: 'quote__picture' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.quote__figure {
|
.quote {
|
||||||
|
/* One statement holds for this long before the next one comes in. */
|
||||||
|
--quote-hold: 9s;
|
||||||
|
}
|
||||||
|
.quote__frame {
|
||||||
display: grid;
|
display: grid;
|
||||||
background: var(--paper-deep);
|
background: var(--paper-deep);
|
||||||
}
|
}
|
||||||
@@ -48,15 +91,180 @@ const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
|||||||
align-content: space-between;
|
align-content: space-between;
|
||||||
padding: var(--space-7) var(--space-6);
|
padding: var(--space-7) var(--space-6);
|
||||||
}
|
}
|
||||||
.quote__essay { text-decoration: none; }
|
|
||||||
.quote__essay:hover { text-decoration: underline; }
|
/* 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__text { text-wrap: balance; max-width: 16em; }
|
||||||
.quote__name { font-weight: var(--weight-medium); }
|
.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;
|
||||||
|
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%; }
|
: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%; }
|
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
|
||||||
@media (min-width: 64rem) {
|
@media (min-width: 64rem) {
|
||||||
.quote__figure { grid-template-columns: 11fr 9fr; }
|
.quote__frame { grid-template-columns: 11fr 9fr; }
|
||||||
.quote__panel { padding: var(--space-8) var(--space-8); }
|
.quote__panel { padding: var(--space-8) var(--space-8); }
|
||||||
.quote__img, :global(.quote__img) { aspect-ratio: auto; }
|
.quote__img, :global(.quote__img) { aspect-ratio: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.quote__slide, .quote__slide > *, .quote__portrait, .quote__portrait :global(img) {
|
||||||
|
transition: none !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
|
||||||
|
* the pointer or 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('pointerenter', () => hold(true));
|
||||||
|
root.addEventListener('pointerleave', () => hold(root.contains(document.activeElement)));
|
||||||
|
root.addEventListener('focusin', () => hold(true));
|
||||||
|
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(root.matches(':hover')); });
|
||||||
|
|
||||||
|
// 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>
|
||||||
+38
-12
@@ -100,12 +100,25 @@ const en = {
|
|||||||
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
|
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
|
||||||
},
|
},
|
||||||
quote: {
|
quote: {
|
||||||
eyebrow: 'From the essay',
|
label: 'Statements from the founders',
|
||||||
text: 'Standard-compliant is not the same as secure.',
|
show: (name: string) => `Show the statement by ${name}`,
|
||||||
name: 'Dr. Felix Klement',
|
slide: (i: number, n: number) => `${i} of ${n}`,
|
||||||
role: 'Founder and CEO',
|
items: [
|
||||||
link: 'Read the essay',
|
{
|
||||||
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
|
eyebrow: 'From the essay “From Open Interfaces to Open Risks”',
|
||||||
|
text: 'Standard-compliant is not the same as secure.',
|
||||||
|
name: 'Dr. Felix Klement',
|
||||||
|
role: 'Founder and CEO',
|
||||||
|
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'On hardware roots of trust',
|
||||||
|
text: 'Software is only as trustworthy as the hardware it runs on.',
|
||||||
|
name: 'Dr. Florian Frank',
|
||||||
|
role: 'Founder and CTO',
|
||||||
|
portraitAlt: 'Portrait of Dr. Florian Frank, founder and CTO of CipherCell.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
insights: {
|
insights: {
|
||||||
title: 'Insights',
|
title: 'Insights',
|
||||||
@@ -394,12 +407,25 @@ const de: typeof en = {
|
|||||||
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
|
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
|
||||||
},
|
},
|
||||||
quote: {
|
quote: {
|
||||||
eyebrow: 'Aus dem Essay',
|
label: 'Aussagen der Gründer',
|
||||||
text: 'Standardkonform ist nicht dasselbe wie sicher.',
|
show: (name: string) => `Aussage von ${name} zeigen`,
|
||||||
name: 'Dr. Felix Klement',
|
slide: (i: number, n: number) => `${i} von ${n}`,
|
||||||
role: 'Gründer und CEO',
|
items: [
|
||||||
link: 'Essay lesen',
|
{
|
||||||
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
|
eyebrow: 'Aus dem Essay „From Open Interfaces to Open Risks“',
|
||||||
|
text: 'Standardkonform ist nicht dasselbe wie sicher.',
|
||||||
|
name: 'Dr. Felix Klement',
|
||||||
|
role: 'Gründer und CEO',
|
||||||
|
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyebrow: 'Über Vertrauensanker in der Hardware',
|
||||||
|
text: 'Software ist nur so vertrauenswürdig wie die Hardware, auf der sie läuft.',
|
||||||
|
name: 'Dr. Florian Frank',
|
||||||
|
role: 'Gründer und CTO',
|
||||||
|
portraitAlt: 'Porträt von Dr. Florian Frank, Gründer und CTO von CipherCell.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
insights: {
|
insights: {
|
||||||
title: 'Einblicke',
|
title: 'Einblicke',
|
||||||
|
|||||||
Reference in new issue
Block a user