This commit is contained in:
Felix Klement committed 2026-09-18 16:04:05 +02:00
commit 4cda1ca41b
72 files changed
+8169

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
---
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const f = ui.contact.form;
---
<section class="section contact" id="contact" aria-labelledby="contact-title">
<div class="container grid">
<div class="contact__text">
<h2 id="contact-title" class="h2">{ui.contact.title}</h2>
<p class="contact__body">{ui.contact.body}</p>
<address class="contact__address">
<p>{ui.contact.address[0]}<br />{ui.contact.address[1]}</p>
<p><a href={ui.contact.phoneHref}>{ui.contact.phone}</a><br /><a href={`mailto:${ui.contact.email}`}>{ui.contact.email}</a></p>
</address>
</div>
<form class="contact__form" action="/contact" method="post" lang={lang}>
<p class="visually-hidden" aria-hidden="true">
<label for="website">{f.honeypot}</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
</p>
<div class="field">
<label for="first_name">{f.firstName} <span aria-hidden="true">*</span></label>
<input id="first_name" name="first_name" type="text" autocomplete="given-name" required />
</div>
<div class="field">
<label for="last_name">{f.lastName} <span aria-hidden="true">*</span></label>
<input id="last_name" name="last_name" type="text" autocomplete="family-name" required />
</div>
<div class="field field--full">
<label for="email">{f.email} <span aria-hidden="true">*</span></label>
<input id="email" name="email" type="email" autocomplete="email" required />
</div>
<div class="field field--full">
<label for="message">{f.message} <span aria-hidden="true">*</span></label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<div class="field--full contact__actions">
<button class="button" type="submit">{f.submit}</button>
<p class="small muted"><span aria-hidden="true">* </span>{f.required}</p>
</div>
</form>
</div>
</section>
<style>
.contact { border-top: var(--hairline) solid var(--line); }
.contact__text {
grid-column: 1 / -1;
display: grid;
gap: var(--space-6);
align-content: start;
}
.contact__body { max-width: 28em; color: var(--ink-muted); }
.contact__address { font-style: normal; display: grid; gap: var(--space-4); }
.contact__address a { text-decoration: none; }
.contact__address a:hover { text-decoration: underline; }
.contact__form {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-6) var(--gap);
align-content: start;
}
.field { display: grid; gap: var(--space-2); }
.field--full { grid-column: 1 / -1; }
.field label { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
.field input, .field textarea {
width: 100%;
background: transparent;
border: 0;
border-bottom: var(--hairline) solid var(--line);
padding: var(--space-2) 0 var(--space-3);
border-radius: 0;
transition: border-color var(--dur-fast) var(--ease);
}
.field input:focus-visible, .field textarea:focus-visible {
border-bottom-color: currentColor;
}
.field textarea { resize: vertical; min-height: 8rem; }
.contact__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-5);
margin-top: var(--space-2);
}
@media (min-width: 48rem) {
.contact__text { grid-column: 1 / span 5; }
.contact__form { grid-column: 7 / span 6; }
}
@media (max-width: 30rem) {
.contact__form { grid-template-columns: 1fr; }
.field { grid-column: 1 / -1; }
}
</style>
+107
View File
@@ -0,0 +1,107 @@
---
/**
* Grid objects: wireframe solids drawn as technical line drawings.
* Single stroke in currentColor, even width, no fills. The geometry is computed
* at build time so the markup stays small and the stroke stays exact.
* Kinds: sphere (a model seen from every side), cube (a built structure),
* plane (one element lifted out for inspection), wave (a signal over time).
*/
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave'; class?: string }
const { kind, class: className } = Astro.props;
const S = 400;
const f = (n: number) => Math.round(n * 10) / 10;
const poly = (pts: [number, number][]) => pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
const iso = (x: number, y: number, z: number, cy = ISO.cy): [number, number] =>
[ISO.cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
const lines: string[] = [];
if (kind === 'sphere') {
const R = 150, tilt = (22 * Math.PI) / 180, N = 48;
const p3 = (lat: number, lon: number): [number, number] => {
const x = R * Math.cos(lat) * Math.sin(lon);
const y = R * Math.sin(lat);
const z = R * Math.cos(lat) * Math.cos(lon);
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
return [ISO.cx + x, ISO.cy - y2];
};
// Parallels every 20 degrees, meridians every 22.5 degrees (8 great circles).
for (let d = -80; d <= 80; d += 20) {
const lat = (d * Math.PI) / 180;
const pts: [number, number][] = [];
for (let i = 0; i <= N; i++) pts.push(p3(lat, (i / N) * 2 * Math.PI));
lines.push(poly(pts));
}
for (let m = 0; m < 8; m++) {
const lon = (m * Math.PI) / 8;
const pts: [number, number][] = [];
for (let i = 0; i <= N; i++) pts.push(p3((i / N) * 2 * Math.PI - Math.PI, lon));
lines.push(poly(pts));
}
lines.push(`circle:${R}`);
}
if (kind === 'cube') {
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
for (let i = 0; i <= n; i++) {
const t = i * u;
// top face (y = L)
lines.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
lines.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
// front-right face (z = 0): x by y
lines.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
// front-left face (x = 0): z by y
lines.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
lines.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
}
}
if (kind === 'plane') {
const n = 6, u = 34, L = n * u, cy = ISO.cy + 20, h = 96, tx = 2, tz = 2;
for (let i = 0; i <= n; i++) {
const t = i * u;
lines.push(poly([iso(t, 0, 0, cy), iso(t, 0, L, cy)]));
lines.push(poly([iso(0, 0, t, cy), iso(L, 0, t, cy)]));
}
const c: [number, number][] = [[tx, tz], [tx + 1, tz], [tx + 1, tz + 1], [tx, tz + 1]];
const top = c.map(([x, z]) => iso(x * u, h, z * u, cy));
lines.push(poly([...top, top[0]]));
c.forEach(([x, z], i) => lines.push(poly([iso(x * u, 0, z * u, cy), top[i]])));
}
if (kind === 'wave') {
const nx = 12, nz = 8, u = 26, cy = ISO.cy + 10, A = 26;
const yAt = (x: number, z: number) => A * Math.sin((x / nx) * Math.PI * 2 - 0.6) * Math.cos((z / nz) * Math.PI * 0.9);
const P = (x: number, z: number) => iso((x - nx / 2) * u, yAt(x, z), (z - nz / 2) * u, cy);
for (let z = 0; z <= nz; z++) {
const pts: [number, number][] = [];
for (let x = 0; x <= nx; x++) pts.push(P(x, z));
lines.push(poly(pts));
}
for (let x = 0; x <= nx; x++) {
const pts: [number, number][] = [];
for (let z = 0; z <= nz; z++) pts.push(P(x, z));
lines.push(poly(pts));
}
}
---
<svg class:list={['grid-object', className]} viewBox={`0 0 ${S} ${S}`} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
{lines.map((l) => l.startsWith('circle:')
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
: <polyline points={l} />)}
</g>
</svg>
<style>
.grid-object {
width: 100%;
height: auto;
color: var(--ink);
}
</style>
+86
View File
@@ -0,0 +1,86 @@
---
import { Picture } from 'astro:assets';
import plant from '../../assets/images/plant-at-dusk.jpg';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
---
<section class="hero night" aria-labelledby="hero-title">
<div class="hero__media">
<Picture
src={plant}
alt={ui.hero.imageAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[640, 960, 1440, 1920, 2400, 3024]}
sizes="100vw"
loading="eager"
fetchpriority="high"
decoding="async"
class="hero__img img-cover img-doc"
pictureAttributes={{ class: 'hero__picture' }}
/>
<div class="hero__overlay"></div>
</div>
<div class="container hero__content">
<h1 id="hero-title" class="display hero__title">
{ui.hero.lines.map((line, i) => (
<span class="hero__line" style={`--i: ${i}`}>{line}</span>
))}
</h1>
</div>
</section>
<style>
.hero {
position: relative;
display: flex;
align-items: flex-end;
min-height: clamp(30rem, calc(100svh - var(--header-height)), 56rem);
overflow: hidden;
}
.hero__media { position: absolute; inset: 0; }
:global(.hero__picture) { display: block; width: 100%; height: 100%; }
.hero__img,
:global(.hero__img) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 50%;
transform-origin: 50% 60%;
animation: hero-settle var(--dur-hero) var(--ease) both;
}
.hero__overlay {
position: absolute;
inset: 0;
background: var(--overlay);
}
.hero__content {
position: relative;
width: 100%;
padding-block: var(--space-9);
}
.hero__title { max-width: 12em; }
.hero__line {
display: block;
opacity: 0;
animation: hero-rise 700ms var(--ease) forwards;
animation-delay: calc(240ms + var(--i) * 160ms);
}
@keyframes hero-settle {
from { transform: scale(1.045); }
to { transform: none; }
}
@keyframes hero-rise {
from { opacity: 0; transform: translateY(0.18em); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
}
</style>
+91
View File
@@ -0,0 +1,91 @@
---
import { getCollection } from 'astro:content';
import { Picture } from 'astro:assets';
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const posts = (await getCollection('insights'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 2);
---
<section class="section insights" aria-labelledby="insights-title">
<div class="container">
<div class="insights__head">
<h2 id="insights-title" class="h2">{ui.insights.title}</h2>
<a class="link" href={localePath(lang, '/insights')}>{ui.insights.all}</a>
</div>
<ul class="insights__list" role="list">
{posts.map((post) => (
<li class="insights__item">
<a class="insights__card" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
<Picture
src={post.data.cover}
alt={post.data.coverAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[400, 640, 960]}
sizes="(min-width: 64rem) 30vw, (min-width: 48rem) 45vw, 100vw"
loading="lazy"
decoding="async"
class="insights__img img-doc"
/>
<p class="small muted">{post.data.category}</p>
<h3 class="h3">{post.data.title}</h3>
</a>
</li>
))}
<li class="insights__item">
<a class="insights__card insights__card--event" href={ui.insights.event.href} rel="noopener">
<p class="insights__event-when">{ui.insights.event.when}</p>
<p class="small muted">{ui.insights.event.category}</p>
<h3 class="h3">{ui.insights.event.text}</h3>
</a>
</li>
</ul>
</div>
</section>
<style>
.insights__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4) var(--space-6);
}
.insights__list {
margin-top: var(--space-8);
display: grid;
gap: var(--space-8) var(--gap);
}
.insights__card {
display: grid;
grid-template-rows: auto auto 1fr;
gap: var(--space-3);
text-decoration: none;
height: 100%;
}
.insights__card:hover h3 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; }
.insights__img, :global(.insights__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; margin-bottom: var(--space-2); }
.insights__card h3 { max-width: 18em; }
.insights__event-when {
display: flex;
align-items: flex-end;
aspect-ratio: 3 / 2;
padding: var(--space-5);
margin-bottom: var(--space-2);
background: var(--paper-deep);
color: var(--ink-muted);
font-size: var(--step-title);
line-height: var(--leading-title);
letter-spacing: var(--track-h2);
}
@media (min-width: 48rem) {
.insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 64rem) {
.insights__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
</style>
+36
View File
@@ -0,0 +1,36 @@
---
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
---
<section class="section manifesto" aria-labelledby="manifesto-title">
<div class="container grid">
<h2 id="manifesto-title" class="h2 manifesto__title">
{ui.manifesto.lines.map((line) => <span class="line">{line}</span>)}
</h2>
<div class="manifesto__body">
<p>{ui.manifesto.p1}</p>
<p>{ui.manifesto.p2}</p>
<a class="button" href={localePath(lang, '/team')}>{ui.manifesto.cta}</a>
</div>
</div>
</section>
<style>
.line { display: block; }
.manifesto { padding-block: calc(var(--section) * 1.15); }
.manifesto__title { grid-column: 1 / -1; }
.manifesto__body {
grid-column: 1 / -1;
display: grid;
gap: var(--space-5);
justify-items: start;
max-width: var(--measure);
}
.manifesto__body .button { margin-top: var(--space-2); }
@media (min-width: 48rem) {
.manifesto__title { grid-column: 1 / span 6; }
.manifesto__body { grid-column: 7 / span 6; padding-top: var(--space-2); }
}
</style>
+58
View File
@@ -0,0 +1,58 @@
---
import { Picture } from 'astro:assets';
import MethodDiagram from './MethodDiagram.astro';
import engineer from '../../assets/images/engineer-glass.jpg';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
---
<section class="night method" id="capabilities" aria-labelledby="method-title">
<Picture
src={engineer}
alt={ui.method.imageAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[640, 960, 1440, 1920, 2560]}
sizes="100vw"
loading="lazy"
decoding="async"
class="method__img img-cover img-doc"
pictureAttributes={{ class: 'method__picture' }}
/>
<div class="container grid method__intro">
<h2 id="method-title" class="h2 method__title">
{ui.method.lines.map((line, i) => <span class:list={['line', { 'line--muted': i > 0 }]}>{line}</span>)}
</h2>
<p class="method__body">{ui.method.body}</p>
</div>
<div class="container method__figure">
<MethodDiagram title={ui.method.diagramTitle} desc={ui.method.diagramDesc} stages={ui.method.stages} />
</div>
</section>
<style>
.line { display: block; }
.line--muted { color: var(--ink-muted); }
:global(.method__picture) { display: block; width: 100%; }
.method__img, :global(.method__img) {
width: 100%;
height: clamp(16rem, 36vw, 32rem);
object-fit: cover;
object-position: 50% 40%;
}
.method__intro {
padding-block: var(--section) var(--space-9);
align-items: start;
}
.method__title { grid-column: 1 / -1; }
.method__body { grid-column: 1 / -1; max-width: 30em; }
.method__figure { padding-bottom: var(--section); }
.method__figure :global(.diagram) { max-width: 78rem; margin-inline: auto; }
@media (min-width: 48rem) {
.method__title { grid-column: 1 / span 6; }
.method__body { grid-column: 7 / span 5; padding-top: var(--space-2); }
}
</style>
+125
View File
@@ -0,0 +1,125 @@
---
/**
* The method as a closed loop of four stages, drawn as a spec-sheet diagram.
* Main nodes sit on the corners of the loop with their labels inside;
* each stage's instruments fan outwards. Wide and tall compositions of the same drawing.
*/
interface Stage { title: string; items: string[] }
interface Props { title: string; desc: string; stages: Stage[] }
const { title, desc, stages } = Astro.props;
const stroke = 1.25;
const ring = 36;
/* Wide composition: 1400 x 560. Nodes on the corners of a loop. */
const W = { w: 1400, h: 560, x1: 420, x2: 980, y1: 140, y2: 420, sat: 175, dy: 62 };
const wideNodes = [
{ x: W.x1, y: W.y1, side: -1, up: -1 }, // top-left: threat analysis
{ x: W.x2, y: W.y1, side: 1, up: -1 }, // top-right: engineering
{ x: W.x2, y: W.y2, side: 1, up: 1 }, // bottom-right: verification
{ x: W.x1, y: W.y2, side: -1, up: 1 }, // bottom-left: monitoring
];
/* Tall composition: 400 x 780. Nodes stacked on a spine, the loop closes on the left.
The title sits right of the node, its instruments hang below the title. */
const T = { w: 400, h: 780, x: 60, loopX: 20, y0: 60, step: 200, ring: 28, labelX: 40, satX: 44, satY: 36, dy: 28 };
const tallNodes = stages.map((_, i) => ({ x: T.x, y: T.y0 + i * T.step }));
/* Direction tick: a small chevron at the midpoint of an edge, pointing along it. */
const tick = (x: number, y: number, dx: number, dy: number, s = 7) => {
const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`;
};
---
<svg class="diagram diagram--wide" viewBox={`0 0 ${W.w} ${W.h}`} role="img" aria-labelledby="method-w-title method-w-desc" xmlns="http://www.w3.org/2000/svg">
<title id="method-w-title">{title}</title>
<desc id="method-w-desc">{desc}</desc>
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke">
<rect x={W.x1} y={W.y1} width={W.x2 - W.x1} height={W.y2 - W.y1} />
<polyline points={tick((W.x1 + W.x2) / 2, W.y1, 1, 0)} />
<polyline points={tick(W.x2, (W.y1 + W.y2) / 2, 0, 1)} />
<polyline points={tick((W.x1 + W.x2) / 2, W.y2, -1, 0)} />
<polyline points={tick(W.x1, (W.y1 + W.y2) / 2, 0, -1)} />
{wideNodes.map((n, i) => (
<g>
<circle cx={n.x} cy={n.y} r={ring} stroke-dasharray="2 5" />
{stages[i].items.map((_, k) => {
const sx = n.x + n.side * W.sat;
const sy = n.y + (k - 1) * W.dy;
return <line x1={n.x} y1={n.y} x2={sx} y2={sy} class="diagram__branch" />;
})}
</g>
))}
</g>
<g fill="currentColor">
{wideNodes.map((n, i) => (
<g>
<circle cx={n.x} cy={n.y} r="5" />
{stages[i].items.map((_, k) => <circle cx={n.x + n.side * W.sat} cy={n.y + (k - 1) * W.dy} r="3" class="diagram__dot" />)}
</g>
))}
</g>
<g class="diagram__labels" font-size="17" font-weight="500">
{wideNodes.map((n, i) => (
<text x={n.x - n.side * (ring + 14)} y={n.y - n.up * 30 + 6} text-anchor={n.side < 0 ? 'start' : 'end'}>{stages[i].title}</text>
))}
</g>
<g class="diagram__meta" font-size="15">
{wideNodes.map((n, i) => stages[i].items.map((item, k) => (
<text x={n.x + n.side * (W.sat + 14)} y={n.y + (k - 1) * W.dy + 5} text-anchor={n.side < 0 ? 'end' : 'start'}>{item}</text>
)))}
</g>
</svg>
<svg class="diagram diagram--tall" viewBox={`0 0 ${T.w} ${T.h}`} role="img" aria-labelledby="method-t-title method-t-desc" xmlns="http://www.w3.org/2000/svg">
<title id="method-t-title">{title}</title>
<desc id="method-t-desc">{desc}</desc>
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke">
<line x1={T.x} y1={tallNodes[0].y} x2={T.x} y2={tallNodes[3].y} />
<polyline points={`${T.x},${tallNodes[3].y} ${T.loopX},${tallNodes[3].y} ${T.loopX},${tallNodes[0].y} ${T.x},${tallNodes[0].y}`} />
{tallNodes.slice(0, 3).map((n) => <polyline points={tick(T.x, n.y + T.step / 2 + 20, 0, 1)} />)}
<polyline points={tick(T.loopX, (tallNodes[0].y + tallNodes[3].y) / 2, 0, -1)} />
{tallNodes.map((n, i) => (
<g>
<circle cx={n.x} cy={n.y} r={T.ring} stroke-dasharray="2 5" />
{stages[i].items.map((_, k) => <line x1={n.x} y1={n.y} x2={n.x + T.satX} y2={n.y + T.satY + k * T.dy} class="diagram__branch" />)}
</g>
))}
</g>
<g fill="currentColor">
{tallNodes.map((n, i) => (
<g>
<circle cx={n.x} cy={n.y} r="5" />
{stages[i].items.map((_, k) => <circle cx={n.x + T.satX} cy={n.y + T.satY + k * T.dy} r="3" class="diagram__dot" />)}
</g>
))}
</g>
<g class="diagram__labels" font-size="16" font-weight="500">
{tallNodes.map((n, i) => <text x={n.x + T.labelX} y={n.y + 6}>{stages[i].title}</text>)}
</g>
<g class="diagram__meta" font-size="14">
{tallNodes.map((n, i) => stages[i].items.map((item, k) => (
<text x={n.x + T.satX + 12} y={n.y + T.satY + k * T.dy + 5}>{item}</text>
)))}
</g>
</svg>
<style>
.diagram {
width: 100%;
height: auto;
color: var(--ink);
font-family: var(--font-sans);
}
.diagram__labels { fill: var(--ink); }
.diagram__meta { fill: var(--ink-muted); }
.diagram__branch { stroke: var(--line); }
.diagram__dot { fill: var(--ink-muted); }
.diagram--tall { display: none; }
/* Below 64rem the wide drawing would scale its labels under 11px; the tall one takes over. */
@media (max-width: 63.99rem) {
.diagram--wide { display: none; }
.diagram--tall { display: block; max-width: 26rem; }
}
</style>
+39
View File
@@ -0,0 +1,39 @@
---
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
---
<section class="section proof" aria-labelledby="proof-title">
<div class="container grid">
<h2 id="proof-title" class="h2 proof__title">{ui.proof.title}</h2>
<ul class="proof__list" role="list">
{ui.proof.items.map((p) => (
<li class="proof__item">
<p class="proof__name">{p.name}</p>
<p class="muted proof__body">{p.body}</p>
</li>
))}
</ul>
</div>
</section>
<style>
.proof { border-top: var(--hairline) solid var(--line); }
.proof__title { grid-column: 1 / -1; }
.proof__list { grid-column: 1 / -1; }
.proof__item {
display: grid;
gap: var(--space-2);
padding-block: var(--space-5);
border-top: var(--hairline) solid var(--line);
}
.proof__item:last-child { border-bottom: var(--hairline) solid var(--line); }
.proof__name { font-weight: var(--weight-medium); }
.proof__body { max-width: 30em; }
@media (min-width: 48rem) {
.proof__title { grid-column: 1 / span 6; }
.proof__list { grid-column: 7 / span 6; }
.proof__item { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); column-gap: var(--gap); }
}
</style>
+62
View File
@@ -0,0 +1,62 @@
---
import { Picture } from 'astro:assets';
import felix from '../../assets/images/people/felix.png';
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const essay = localePath(lang, '/insights/xg-aloe-user-forum');
---
<section class="section--tight quote" aria-labelledby="quote-text">
<div class="container">
<figure class="quote__figure">
<div class="quote__panel">
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p>
<blockquote>
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p>
</blockquote>
<figcaption class="quote__caption">
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p>
</figcaption>
</div>
<div class="quote__portrait">
<Picture
src={felix}
alt={ui.quote.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>
</figure>
</div>
</section>
<style>
.quote__figure {
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);
}
.quote__essay { text-decoration: none; }
.quote__essay:hover { text-decoration: underline; }
.quote__text { text-wrap: balance; max-width: 16em; }
.quote__name { font-weight: var(--weight-medium); }
: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__figure { grid-template-columns: 11fr 9fr; }
.quote__panel { padding: var(--space-8) var(--space-8); }
.quote__img, :global(.quote__img) { aspect-ratio: auto; }
}
</style>
+166
View File
@@ -0,0 +1,166 @@
---
import { Picture } from 'astro:assets';
import fiveG from '../../assets/images/sector-5g.jpg';
import substation from '../../assets/images/sector-substation.jpg';
import datacentre from '../../assets/images/sector-datacentre.jpg';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const images = [fiveG, substation, datacentre];
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id: `sector-${i + 1}` }));
---
<section class="section sectors" id="sectors" aria-labelledby="sectors-title">
<div class="container sectors__grid" data-sectors>
<div class="sectors__text">
<h2 id="sectors-title" class="h2">{ui.sectors.title}</h2>
<div class="sectors__tabs" role="tablist" aria-orientation="vertical" aria-labelledby="sectors-title">
{items.map((item, i) => (
<div class="sectors__tab" data-tab>
<h3 class="sectors__heading">
<button
class="sectors__button"
type="button"
role="tab"
id={`${item.id}-tab`}
aria-controls={`${item.id}-panel`}
aria-selected={i === 0 ? 'true' : 'false'}
tabindex={i === 0 ? undefined : -1}
>{item.title}</button>
</h3>
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0">
<div class="sectors__panel-inner">
<p class="muted">{item.body}</p>
<Picture
src={item.image}
alt={item.imageAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[480, 768, 1024, 1440]}
sizes="(min-width: 64rem) 50vw, 100vw"
loading="lazy"
decoding="async"
class="sectors__img img-doc"
pictureAttributes={{ class: 'sectors__picture' }}
/>
</div>
</div>
</div>
))}
</div>
</div>
<div class="sectors__media" aria-hidden="true">
{items.map((item, i) => (
<Picture
src={item.image}
alt=""
formats={['avif']}
fallbackFormat="webp"
widths={[480, 768, 1024, 1440]}
sizes="(min-width: 64rem) 50vw, 100vw"
loading="lazy"
decoding="async"
class="sectors__img img-doc"
pictureAttributes={{ class: 'sectors__picture sectors__picture--stage', 'data-image': String(i), 'data-active': i === 0 ? '' : undefined }}
/>
))}
</div>
</div>
</section>
<style>
.sectors__grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--gap);
row-gap: var(--space-7);
}
.sectors__text {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.sectors__tabs { margin-top: auto; }
.sectors__tab + .sectors__tab { margin-top: var(--space-5); }
.sectors__heading { font-size: inherit; font-weight: inherit; }
.sectors__button {
display: block;
text-align: left;
font-size: var(--step-h3);
line-height: var(--leading-h3);
letter-spacing: var(--track-h3);
font-weight: var(--weight-medium);
color: var(--ink);
transition: color var(--dur-fast) var(--ease);
}
.sectors__button[aria-selected='false'] { color: var(--ink-muted); }
.sectors__button[aria-selected='false']:hover { color: var(--ink); }
.sectors__panel-inner { display: grid; gap: var(--space-5); padding-top: var(--space-3); }
.sectors__panel p { max-width: 28em; }
:global(.sectors__picture) { display: block; width: 100%; }
.sectors__img, :global(.sectors__img) { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; }
/* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */
.sectors__media { display: none; }
/* With scripting, one panel at a time; without, everything is readable. */
:global(html.js) .sectors__panel {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows var(--dur-slow) var(--ease);
}
:global(html.js) .sectors__panel-inner { min-height: 0; }
:global(html.js) .sectors__panel[data-open] { grid-template-rows: 1fr; }
@media (min-width: 64rem) {
.sectors__text { grid-column: 1 / span 5; }
.sectors__media {
grid-column: 7 / span 6;
display: grid;
}
.sectors__media :global(.sectors__picture--stage) {
grid-area: 1 / 1;
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
}
.sectors__media :global(.sectors__picture--stage[data-active]) { opacity: 1; }
/* Inside the panels the image is redundant on wide screens. */
.sectors__panel :global(.sectors__picture) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.sectors__panel, .sectors__media :global(.sectors__picture--stage) { transition: none; }
}
</style>
<script>
const root = document.querySelector<HTMLElement>('[data-sectors]');
if (root) {
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
const panels = tabs.map((t) => document.getElementById(t.getAttribute('aria-controls')!)!);
const images = Array.from(root.querySelectorAll<HTMLElement>('[data-image]'));
const select = (index: number, focus = false) => {
tabs.forEach((tab, i) => {
const on = i === index;
tab.setAttribute('aria-selected', String(on));
if (on) tab.removeAttribute('tabindex'); else tab.setAttribute('tabindex', '-1');
if (on) panels[i].setAttribute('data-open', ''); else panels[i].removeAttribute('data-open');
if (on) images[i]?.setAttribute('data-active', ''); else images[i]?.removeAttribute('data-active');
});
if (focus) tabs[index].focus();
};
tabs.forEach((tab, i) => {
tab.addEventListener('click', () => select(i));
tab.addEventListener('keydown', (e) => {
const n = tabs.length;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); select((i + 1) % n, true); }
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); select((i - 1 + n) % n, true); }
if (e.key === 'Home') { e.preventDefault(); select(0, true); }
if (e.key === 'End') { e.preventDefault(); select(n - 1, true); }
});
});
select(0);
}
</script>
+63
View File
@@ -0,0 +1,63 @@
---
import GridObject from './GridObject.astro';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
---
<section class="section--tight stages" aria-labelledby="stages-title">
<div class="container">
<h2 id="stages-title" class="eyebrow stages__eyebrow">{ui.stages.eyebrow}</h2>
<ol class="stages__list" role="list">
{ui.stages.items.map((item, i) => (
<li class="stages__item" id={`capability-${i + 1}`}>
<h3 class="stages__title">{item.title}</h3>
<GridObject kind={objects[i]} class="stages__object" />
<p class="muted stages__body">{item.body}</p>
</li>
))}
</ol>
</div>
</section>
<style>
.stages__eyebrow { margin-bottom: var(--space-6); }
.stages__list {
display: grid;
gap: var(--gap);
}
.stages__item {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr;
align-items: start;
gap: var(--space-6);
padding: var(--space-6) var(--space-6) var(--space-7);
background: var(--paper-deep);
scroll-margin-top: calc(var(--header-height) + var(--space-5));
}
.stages__title {
font-size: var(--step-title);
line-height: var(--leading-title);
letter-spacing: var(--track-h2);
font-weight: var(--weight-regular);
/* Two lines reserved, so objects and bodies line up across the row. */
min-height: calc(2 * var(--leading-title) * 1em);
hyphens: auto;
}
.stages__item :global(.stages__object) {
width: 100%;
max-width: 16rem;
justify-self: center;
align-self: center;
}
.stages__body { max-width: 24em; }
@media (min-width: 48rem) {
.stages__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 64rem) {
.stages__list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.stages__item :global(.stages__object) { max-width: 13rem; }
}
</style>