initial
This commit is contained in:
commit
4cda1ca41b
72 files changed
+8169
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user