initial
No files matched your search
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 549 KiB |
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import Wordmark from './Wordmark.astro';
|
||||
import { localePath, switchLang, t, type Lang } from '../i18n/ui';
|
||||
import fundingEn from '../assets/images/bmftr-en.jpg';
|
||||
import fundingDe from '../assets/images/bmftr-de.jpg';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const home = localePath(lang, '/');
|
||||
const year = new Date().getFullYear();
|
||||
const funding = lang === 'de' ? fundingDe : fundingEn;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: ui.footer.capabilities,
|
||||
links: ui.stages.items.map((item, i) => ({ href: `${home}#capability-${i + 1}`, label: item.title })),
|
||||
},
|
||||
{
|
||||
title: ui.footer.company,
|
||||
links: [
|
||||
{ href: localePath(lang, '/team'), label: ui.nav.team },
|
||||
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
|
||||
{ href: `${home}#sectors`, label: ui.nav.sectors },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: ui.footer.contact,
|
||||
links: [
|
||||
{ href: `${home}#contact`, label: ui.nav.contact },
|
||||
{ href: `mailto:${ui.contact.email}`, label: ui.footer.email },
|
||||
{ href: 'https://github.com/CipherCell-DEV', label: ui.footer.github, external: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: ui.footer.legal,
|
||||
links: [
|
||||
{ href: localePath(lang, '/imprint'), label: ui.footer.imprint },
|
||||
{ href: localePath(lang, '/privacy'), label: ui.footer.privacy },
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<hr class="rule" />
|
||||
<div class="grid footer__top">
|
||||
<div class="footer__brand">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<p class="footer__mission">{ui.footer.mission}</p>
|
||||
|
||||
<nav class="footer__nav" aria-label="Footer">
|
||||
<ul class="footer__lang" role="list" aria-label={ui.footer.language}>
|
||||
<li><a href={switchLang(Astro.url, 'en')} hreflang="en" lang="en" aria-current={lang === 'en' ? 'true' : undefined}>{ui.footer.en}</a></li>
|
||||
<li><a href={switchLang(Astro.url, 'de')} hreflang="de" lang="de" aria-current={lang === 'de' ? 'true' : undefined}>{ui.footer.de}</a></li>
|
||||
</ul>
|
||||
<div class="footer__cols">
|
||||
{columns.map((col) => (
|
||||
<div class="footer__col">
|
||||
<h2 class="footer__heading">{col.title}</h2>
|
||||
<ul role="list" class="footer__links">
|
||||
{col.links.map((l) => (
|
||||
<li><a href={l.href} rel={'external' in l && l.external ? 'noopener' : undefined}>{l.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<hr class="rule" />
|
||||
<div class="footer__bottom">
|
||||
<p class="small muted">{ui.footer.copyright(year)}</p>
|
||||
<Image src={funding} alt={ui.footer.fundingAlt} width={220} densities={[1, 2]} loading="lazy" class="footer__funding" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer { padding-block: var(--space-6) var(--space-7); }
|
||||
.footer__top {
|
||||
padding-block: var(--space-8);
|
||||
row-gap: var(--space-8);
|
||||
}
|
||||
.footer__brand { grid-column: 1 / -1; }
|
||||
.footer__mission {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--step-title);
|
||||
line-height: var(--leading-title);
|
||||
letter-spacing: var(--track-h2);
|
||||
max-width: 16em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.footer__nav {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.footer__lang { display: flex; gap: var(--space-5); }
|
||||
.footer__lang a { text-decoration: none; color: var(--ink-muted); }
|
||||
.footer__lang a:hover { text-decoration: underline; }
|
||||
.footer__lang a[aria-current='true'] { color: var(--ink); font-weight: var(--weight-medium); }
|
||||
.footer__cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-7) var(--gap);
|
||||
}
|
||||
.footer__heading {
|
||||
font-size: var(--step-small);
|
||||
line-height: var(--leading-small);
|
||||
font-weight: var(--weight-regular);
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.footer__links li + li { margin-top: var(--space-2); }
|
||||
.footer__links a { text-decoration: none; }
|
||||
.footer__links a:hover { text-decoration: underline; }
|
||||
.footer__bottom {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
/* The ministry badge ships on a white field; multiply lets the paper show through without recolouring the mark. */
|
||||
.footer__funding { width: 11rem; height: auto; mix-blend-mode: multiply; }
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.footer__brand { grid-column: 1 / span 6; grid-row: 1; }
|
||||
.footer__mission { grid-column: 7 / span 6; grid-row: 1; }
|
||||
.footer__nav { grid-column: 1 / -1; grid-row: 2; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gap); align-items: end; }
|
||||
.footer__lang { grid-column: 1 / span 6; flex-direction: column; gap: var(--space-2); }
|
||||
.footer__cols { grid-column: 7 / span 6; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
import Wordmark from './Wordmark.astro';
|
||||
import { localePath, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const home = localePath(lang, '/');
|
||||
const items = [
|
||||
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
|
||||
{ href: `${home}#sectors`, label: ui.nav.sectors },
|
||||
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
|
||||
{ href: localePath(lang, '/team'), label: ui.nav.team },
|
||||
];
|
||||
const contactHref = `${home}#contact`;
|
||||
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||
const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#');
|
||||
---
|
||||
<header class="header" data-header>
|
||||
<div class="container header__inner">
|
||||
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}><Wordmark /></a>
|
||||
|
||||
<nav class="header__nav" aria-label="Primary" id="primary-nav">
|
||||
<ul class="header__list" role="list">
|
||||
{items.map((item) => (
|
||||
<li><a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>{item.label}</a></li>
|
||||
))}
|
||||
<li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<a class="button button--small header__contact" href={contactHref}>{ui.nav.contact}</a>
|
||||
|
||||
<button class="header__toggle" type="button" aria-expanded="false" aria-controls="primary-nav" data-nav-toggle>
|
||||
<span data-label-open>{ui.nav.menu}</span>
|
||||
<span data-label-close hidden>{ui.nav.close}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--paper);
|
||||
border-bottom: var(--hairline) solid var(--line);
|
||||
}
|
||||
.header__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap);
|
||||
min-height: var(--header-height);
|
||||
}
|
||||
.header__home { text-decoration: none; }
|
||||
.header__nav { margin-left: auto; }
|
||||
.header__list {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.header__list a {
|
||||
text-decoration: none;
|
||||
font-weight: var(--weight-regular);
|
||||
padding-block: var(--space-2);
|
||||
border-bottom: var(--hairline) solid transparent;
|
||||
transition: border-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.header__list a:hover,
|
||||
.header__list a[aria-current='page'] { border-bottom-color: currentColor; }
|
||||
.header__contact { margin-left: var(--space-6); }
|
||||
.header__contact-mobile { display: none; }
|
||||
.header__toggle {
|
||||
display: none;
|
||||
font-weight: var(--weight-medium);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 47.99rem) {
|
||||
.header__contact { display: none; }
|
||||
.header__contact-mobile { display: block; }
|
||||
.header__toggle { display: inline-block; }
|
||||
.header__nav {
|
||||
position: absolute;
|
||||
inset: 100% 0 auto 0;
|
||||
display: none;
|
||||
padding: var(--space-5) var(--gutter) var(--space-7);
|
||||
background: var(--paper);
|
||||
border-bottom: var(--hairline) solid var(--line);
|
||||
}
|
||||
.header__nav[data-open] { display: block; }
|
||||
.header__list { flex-direction: column; gap: 0; }
|
||||
.header__list li + li { border-top: var(--hairline) solid var(--line); }
|
||||
.header__list a {
|
||||
display: block;
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step-h3);
|
||||
letter-spacing: var(--track-h3);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.header__list a[aria-current='page'] { font-weight: var(--weight-medium); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const header = document.querySelector<HTMLElement>('[data-header]');
|
||||
const toggle = header?.querySelector<HTMLButtonElement>('[data-nav-toggle]');
|
||||
const nav = header?.querySelector<HTMLElement>('#primary-nav');
|
||||
if (header && toggle && nav) {
|
||||
const open = toggle.querySelector<HTMLElement>('[data-label-open]')!;
|
||||
const close = toggle.querySelector<HTMLElement>('[data-label-close]')!;
|
||||
const set = (isOpen: boolean) => {
|
||||
toggle.setAttribute('aria-expanded', String(isOpen));
|
||||
if (isOpen) nav.setAttribute('data-open', ''); else nav.removeAttribute('data-open');
|
||||
open.hidden = isOpen;
|
||||
close.hidden = !isOpen;
|
||||
};
|
||||
toggle.addEventListener('click', () => set(toggle.getAttribute('aria-expanded') !== 'true'));
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); });
|
||||
nav.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) set(false); });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
/** The wordmark is set in type. No symbol, no colour. */
|
||||
interface Props { class?: string }
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
<span class:list={['wordmark', className]}>CipherCell</span>
|
||||
|
||||
<style>
|
||||
.wordmark {
|
||||
display: inline-block;
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-h3);
|
||||
font-size: var(--step-h3);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { z } from 'astro/zod';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const insights = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/insights' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
description: z.string(),
|
||||
date: z.coerce.date(),
|
||||
lang: z.enum(['en', 'de']),
|
||||
category: z.string(),
|
||||
readingTime: z.number().int().positive(),
|
||||
author: z.string(),
|
||||
authorRole: z.string(),
|
||||
cover: image(),
|
||||
coverAlt: z.string(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { insights };
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 'From Open Interfaces to Open Risks: Securing the Path to xG'
|
||||
subtitle: 'Reflections from the xG-ALOE User Forum 2026.'
|
||||
description: 'Why standard-compliant is not the same as secure, why nobody regulates your private 5G network, and why security in open networks needs its seatbelt moment.'
|
||||
date: 2026-06-30
|
||||
lang: en
|
||||
category: 'Open RAN'
|
||||
readingTime: 10
|
||||
author: 'Dr. Felix Klement'
|
||||
authorRole: 'Founder and CEO'
|
||||
cover: '../../assets/images/mast-at-dusk.jpg'
|
||||
coverAlt: 'A telecommunications mast at dusk, its warning light lit against a dark sky.'
|
||||
tags: ['O-RAN', 'Security', '5G', 'xG-ALOE', 'Network']
|
||||
---
|
||||
|
||||
## A forum built around real deployments
|
||||
|
||||
On June 24–25, 2026, the open enterprise networks community gathered at Fraunhofer IIS in Nuremberg for the first **xG-ALOE User Forum**, organized by the Fraunhofer-Gesellschaft (Heinrich Hertz Institute HHI and the Institute for Integrated Circuits IIS). The forum brought together users, system integrators, technology providers, and research institutions to share experiences with open campus networks and to look ahead to the evolution from 5G towards xG enterprise networks.
|
||||
|
||||
The format was intentionally designed to be practice-oriented: parallel tutorial tracks and expert-led sessions spanning regulation, O-RAN architectures, testing, monitoring, positioning, service quality and security. The exhibition area kept the conversation flowing between talks, with live demonstrations and plenty of opportunity for the kind of open exchange that only happens when vendors, researchers, operators, and end users share the same room.
|
||||
|
||||
## The security slot: a joint session with the BSI
|
||||
|
||||
Under the title "Securing the Evolution to xG: Insights into 5G and Future Telco Network Security," Heiner Grottendieck (Head of Division SZ31 at the Federal Office for Information Security, BSI) and I, Felix Klement, combined a national-regulatory perspective with academic and applied research.
|
||||
|
||||
In the first part of the session, the BSI provided an insight into its 5G/6G Competence Centre and the TEMIS security laboratory in Freital. There, they have a carrier-grade test environment in which end-to-end networks from Ericsson and Nokia, alongside non-public Open RAN-based networks from Airpuls and CampusGenius, are operated side by side. The talk then turned to the heart of the matter: why the security of private 5G networks is actually important.
|
||||
|
||||
Our part of the session, entitled "From Open Interfaces to Open Risks", focused on architectural aspects: how is the transition to open, decentralized networks changing the security landscape; what specific vulnerabilities is our research uncovering; and how operators can realistically assess and strengthen their security posture.
|
||||
|
||||
## People need pressure to change, and security is exactly that kind of change
|
||||
|
||||
One of the most honest moments of the forum came from the user side. In the panel on day 2, moderated by colleagues from brown-iposs, one conclusion stood out for its striking simplicity: most users just want "a better Wi-Fi." At a reasonable price, of course.
|
||||
|
||||
That expectation is the whole challenge in a single sentence. Security adds complexity. Security adds cost. It rarely shows up in a glossy demo, and it never makes the dashboard look greener. When the buyer's mental model is "Wi-Fi that just works", security is the first thing quietly traded away, and vendors, competing on price and simplicity, are happy to oblige by shipping protective options switched off by default.
|
||||
|
||||
So why insist on it anyway? A few concrete points from the session make the case:
|
||||
|
||||
- **Standard-compliant is not the same as secure.** 3GPP provides strong building blocks but many are optional, and vendors frequently ship them disabled. Disable user-plane integrity on a factory network, for instance, and an attacker who reaches the user plane can manipulate traffic and inject commands into machines while every dashboard stays "green."
|
||||
- **The breach is often operational, not protocol-level.** The way in is usually an unpatched VPN, a default credential, or a "trusted" LAN that extends core trust to the entire IT infrastructure of a factory or hospital, rather than a sophisticated attack on 5G signalling.
|
||||
- **Nobody is regulating your private network.** The BSI/BNetzA security catalog is mandatory only for public networks. Private networks fall outside it and are de facto unregulated. As the BSI put it bluntly: if you run a private 5G network, you are the operator, and the responsibility is yours.
|
||||
- **The consequences are physical.** Production stoppages, property damage, personal injury, and theft of sensitive data are not abstractions when a private 5G core is wired into industrial or clinical systems.
|
||||
|
||||
The encouraging news is that operators don't have to start from scratch. The BSI/BNetzA security catalog, which was created for public networks, is just as suitable as a ready-made blueprint for a private network and can be extended to secure the open interfaces and cross-vendor supply chain associated with O-RAN. And this is precisely where the pressure for change is becoming more urgent: it is not slowing down on the path to xG, it is intensifying. Every step toward more open, more disaggregated, and more AI-driven networks brings additional interfaces, vendors, and control loops, and thus a larger attack surface that must be secured. There are effective tools available for this, but they must be used. The security decisions an operator makes today form the foundation upon which the next generation will be built. Properly securing networks today is not a detour on the path to xG. It is the prerequisite for staying on that path.
|
||||
|
||||
## Conclusion: security needs its seatbelt moment
|
||||
|
||||
If there is one idea I want readers to take from this forum, it is that security is a remarkably versatile topic, and not only in the technical sense. It is shaped at least as much by external factors: economics, regulation, vendor incentives, and, above all, human attitudes. At its core, security is partly a societal problem. Before any security control matrix helps, people have to genuinely believe that security is needed.
|
||||
|
||||
The seatbelt is the analogy we keep returning to. Five decades ago, almost nobody thought seatbelts were necessary; they felt like an inconvenience that solved a problem most drivers were sure they would never have. Today, buckling up is as automatic as putting on your socks in the morning, and driving without a seatbelt feels deeply wrong. Security in private networks needs exactly that transition: from a half-heartedly accepted add-on to something so normal and so obviously desirable that operating without it would feel just as unthinkable as driving without a seatbelt.
|
||||
|
||||
It is worth being honest about the second half of the story: security is an iterative process that will never be fully "solved." The architecture of our systems keeps changing. The threat landscape keeps changing. Attacker capabilities keep changing. Even the scope of what we mean by "security" keeps expanding. Yesterday it was signalling, today it is AI models and supply chains. Security has to evolve continuously, in line with everything it protects.
|
||||
|
||||
That is precisely the work we are committed to at CipherCell, and it is why alliances like xG-ALOE matter: they are where the ecosystem builds the shared understanding and the trust that the path to xG will depend on.
|
||||
|
||||
Interested in learning more about xG-ALOE? Visit [xg-aloe.de](https://xg-aloe.de). To talk with us about assessing and strengthening the security posture of your private 5G or O-RAN deployment, [get in touch with the CipherCell team](/#contact).
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import felix from '../assets/images/people/felix.png';
|
||||
import florian from '../assets/images/people/florian.png';
|
||||
import alexander from '../assets/images/people/alexander.png';
|
||||
import erik from '../assets/images/people/erik.png';
|
||||
import marc from '../assets/images/people/marc.png';
|
||||
import philipp from '../assets/images/people/philipp.png';
|
||||
import karin from '../assets/images/people/karin.png';
|
||||
import valentin from '../assets/images/people/valentin.png';
|
||||
|
||||
export type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM';
|
||||
|
||||
export interface Person {
|
||||
name: string;
|
||||
role: RoleKey;
|
||||
portrait: ImageMetadata;
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const team: Person[] = [
|
||||
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix, github: 'https://github.com/fklement', linkedin: 'https://www.linkedin.com/in/felix-klement', email: 'felix.klement@ciphercell.de' },
|
||||
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian, github: 'https://github.com/FlorianFrank', linkedin: 'https://www.linkedin.com/in/florianfrank93/', email: 'florian.frank@ciphercell.de' },
|
||||
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander, github: 'https://github.com/AlexanderBraml', linkedin: 'https://www.linkedin.com/in/alexanderbraml/', email: 'alexander.braml@ciphercell.de' },
|
||||
{ name: 'Erik Foris', role: 'engineer', portrait: erik, github: 'https://github.com/ErikForis', email: 'erik.foris@ciphercell.de' },
|
||||
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc, email: 'marc.schlotzhauer@ciphercell.de' },
|
||||
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp, github: 'https://github.com/truePhilipp', linkedin: 'https://www.linkedin.com/in/philipp-mayr/', email: 'philipp.mayr@ciphercell.de' },
|
||||
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin, email: 'karin.kaufmann@ciphercell.de' },
|
||||
{ name: 'Valentin Hirte', role: 'studentM', portrait: valentin, email: 'valentin.hirte@ciphercell.de' },
|
||||
];
|
||||
@@ -0,0 +1,323 @@
|
||||
export type Lang = 'en' | 'de';
|
||||
export const langs: Lang[] = ['en', 'de'];
|
||||
export const defaultLang: Lang = 'en';
|
||||
|
||||
export function langFromUrl(url: URL): Lang {
|
||||
const [, first] = url.pathname.split('/');
|
||||
return first === 'de' ? 'de' : 'en';
|
||||
}
|
||||
|
||||
/** Prefix a site-relative path for a language. `path` starts with "/". */
|
||||
export function localePath(lang: Lang, path: string): string {
|
||||
if (lang === 'en') return path === '' ? '/' : path;
|
||||
return path === '/' ? '/de' : `/de${path}`;
|
||||
}
|
||||
|
||||
/** The same page in the other language. */
|
||||
export function switchLang(url: URL, to: Lang): string {
|
||||
const clean = url.pathname.replace(/^\/de(?=\/|$)/, '') || '/';
|
||||
return localePath(to, clean) + url.hash;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date, lang: Lang): string {
|
||||
return new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-GB', {
|
||||
day: 'numeric', month: 'long', year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
const en = {
|
||||
meta: {
|
||||
siteName: 'CipherCell',
|
||||
home: { title: 'CipherCell. Security engineering for complex infrastructure.', description: 'CipherCell is a security engineering company in Passau. Threat analysis, security engineering, verification and monitoring for private 5G, critical network infrastructure and cloud-native systems.' },
|
||||
team: { title: 'Team', description: 'The security researchers, engineers and architects behind CipherCell.' },
|
||||
insights: { title: 'Insights', description: 'Notes on the security of private 5G, critical networks and the systems that run them.' },
|
||||
imprint: { title: 'Legal notice', description: 'Legal notice for the CipherCell website.' },
|
||||
privacy: { title: 'Privacy', description: 'Privacy policy for the CipherCell website.' },
|
||||
},
|
||||
nav: {
|
||||
skip: 'Skip to content',
|
||||
capabilities: 'Capabilities',
|
||||
sectors: 'Sectors',
|
||||
insights: 'Insights',
|
||||
team: 'Team',
|
||||
contact: 'Contact',
|
||||
menu: 'Menu',
|
||||
close: 'Close',
|
||||
homeLabel: 'CipherCell, home',
|
||||
},
|
||||
hero: {
|
||||
lines: ['Security for the networks', 'everything else runs on.'],
|
||||
imageAlt: 'An industrial plant at dusk: pipe racks, storage tanks and a lattice mast carrying 5G antennas under an overcast sky.',
|
||||
},
|
||||
manifesto: {
|
||||
lines: ['Security is built in,', 'not bolted on.'],
|
||||
p1: 'Private 5G networks, industrial control systems and cloud platforms are assembled from the components of many vendors. Every interface between them is an attack surface, and off-the-shelf tools were not built for that.',
|
||||
p2: 'CipherCell is a security engineering company. We model real adversaries, build zero trust into the architecture rather than around it, and prove that the posture holds. From the first threat model to the running system.',
|
||||
cta: 'About CipherCell',
|
||||
},
|
||||
method: {
|
||||
lines: ['Four stages.', 'One method.'],
|
||||
body: 'Threat analysis, engineering, verification and monitoring, run as one continuous method rather than four separate projects. Modular, integrable and built for operational reality.',
|
||||
imageAlt: 'A security engineer seen through glass, the reflections of monitoring screens layered across her face.',
|
||||
diagramTitle: 'The CipherCell method',
|
||||
diagramDesc: 'A closed loop of four stages: threat analysis, engineering, verification and monitoring. Each stage carries its own instruments: threat model, attack paths and CI/CD integration; zero trust, key management and secure APIs; independent review, hardening and testing; intrusion detection, security logging and resilience checks. Monitoring feeds back into threat analysis.',
|
||||
stages: [
|
||||
{ title: 'Threat analysis', items: ['Threat model', 'Attack paths', 'CI/CD integration'] },
|
||||
{ title: 'Engineering', items: ['Zero trust', 'Key management', 'Secure APIs'] },
|
||||
{ title: 'Verification', items: ['Independent review', 'Hardening', 'Testing'] },
|
||||
{ title: 'Monitoring', items: ['Intrusion detection', 'Security logging', 'Resilience checks'] },
|
||||
],
|
||||
},
|
||||
stages: {
|
||||
eyebrow: 'What we do.',
|
||||
items: [
|
||||
{ title: 'Threat analysis', body: 'Automated, empirical analysis of real attack paths. It runs inside your CI/CD pipeline and delivers continuous assurance.' },
|
||||
{ title: 'Engineering', body: 'Zero-trust enforcement, key management and secure API design as modular, composable tools for complex deployments.' },
|
||||
{ title: 'Verification', body: 'Independent review of security measures, hardening checklists and application-level testing across all components.' },
|
||||
{ title: 'Monitoring', body: 'Intrusion detection and security logging, from the first deployment through ongoing resilience verification.' },
|
||||
],
|
||||
},
|
||||
sectors: {
|
||||
title: 'Who we work with',
|
||||
items: [
|
||||
{ title: 'Private 5G and campus networks', body: 'Disaggregated networks with open interfaces and a multi-vendor supply chain. Our origin, and still the hardest case.', imageAlt: 'A private 5G antenna and radio cabinet on the parapet of a factory roof, cranes in the distance.' },
|
||||
{ title: 'Critical network infrastructure', body: 'Networks where a breach stops production, damages equipment or endangers people.', imageAlt: 'An electrical substation seen straight from above: transformers, switchgear and a service road.' },
|
||||
{ title: 'Cloud-native systems', body: 'Containers, service meshes and CI/CD pipelines that carry infrastructure workloads. Security that runs as code, alongside yours.', imageAlt: 'A cold aisle between server racks in a data centre, a technician far in the distance.' },
|
||||
],
|
||||
},
|
||||
proof: {
|
||||
title: 'Where we come from.',
|
||||
items: [
|
||||
{ name: 'University of Passau', body: 'Our method grew out of academic security research. The university remains our research partner.' },
|
||||
{ name: 'xG-ALOE alliance and xG-Incubator', body: 'Partners in the alliance for open enterprise networks, from lab to deployment.' },
|
||||
{ name: 'Federal Ministry of Research, Technology and Space', body: 'Our work is publicly funded.' },
|
||||
],
|
||||
},
|
||||
quote: {
|
||||
eyebrow: 'From the essay',
|
||||
text: 'Standard-compliant is not the same as secure.',
|
||||
name: 'Dr. Felix Klement',
|
||||
role: 'Founder and CEO',
|
||||
link: 'Read the essay',
|
||||
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
|
||||
},
|
||||
insights: {
|
||||
title: 'Insights',
|
||||
all: 'All insights',
|
||||
intro: 'Notes on the security of private 5G, critical networks and the systems that run them.',
|
||||
readingTime: (min: number) => `${min} min read`,
|
||||
event: { category: 'Event', when: 'September 2026', text: 'Meet us at the xG‑ALOE Plugfest 2026.', link: 'Event details', href: 'https://xg-aloe.de/xg-aloe-plugfest-2026/' },
|
||||
back: 'All insights',
|
||||
by: 'By',
|
||||
inEnglish: 'This essay is available in English.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Talk to us.',
|
||||
body: 'Complex infrastructure, a tight compliance deadline, or a problem no one has solved yet. We respond within 24 hours.',
|
||||
address: ['Innstr. 43', '94032 Passau, Germany'],
|
||||
phone: '+49 (0)851 / 509-3218',
|
||||
phoneHref: 'tel:+498515093218',
|
||||
email: 'office@ciphercell.de',
|
||||
form: {
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
message: 'Message',
|
||||
required: 'Required.',
|
||||
honeypot: 'Leave this field empty',
|
||||
submit: 'Send message',
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
mission: 'Security engineering for infrastructure where failure has consequences.',
|
||||
company: 'Company',
|
||||
capabilities: 'Capabilities',
|
||||
contact: 'Contact',
|
||||
legal: 'Legal',
|
||||
language: 'Language',
|
||||
imprint: 'Legal notice',
|
||||
privacy: 'Privacy',
|
||||
github: 'GitHub',
|
||||
email: 'Email',
|
||||
fundingAlt: 'With funding from the Federal Ministry of Research, Technology and Space.',
|
||||
copyright: (year: number) => `© ${year} CipherCell`,
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
},
|
||||
team: {
|
||||
title: ['Researchers,', 'engineers,', 'architects.'],
|
||||
intro: 'The people who go deep on the problems that matter, from 5G and private networks to hardware, software and cloud-native systems.',
|
||||
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
|
||||
links: { github: 'GitHub', linkedin: 'LinkedIn', email: 'Email' },
|
||||
roles: {
|
||||
ceo: 'Founder and CEO',
|
||||
cto: 'Founder and CTO',
|
||||
engineer: 'Software engineer',
|
||||
bizdev: 'Business development',
|
||||
studentF: 'Working student, development',
|
||||
studentM: 'Working student, development',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
body: 'The address you followed leads nowhere. The pages that exist are listed in the footer.',
|
||||
link: 'Go to the start page',
|
||||
},
|
||||
legal: {
|
||||
imprintEyebrow: 'Information pursuant to § 5 TMG',
|
||||
responsible: 'Responsible',
|
||||
mailing: 'Mailing address',
|
||||
contact: 'Contact',
|
||||
asOf: 'As of 14 June 2025',
|
||||
},
|
||||
};
|
||||
|
||||
const de: typeof en = {
|
||||
meta: {
|
||||
siteName: 'CipherCell',
|
||||
home: { title: 'CipherCell. Security Engineering für komplexe Infrastruktur.', description: 'CipherCell ist ein Security-Engineering-Unternehmen in Passau. Bedrohungsanalyse, Security Engineering, Verifikation und Monitoring für private 5G-Netze, kritische Netzinfrastruktur und Cloud-native Systeme.' },
|
||||
team: { title: 'Team', description: 'Die Sicherheitsforschenden, Ingenieurinnen und Architekten hinter CipherCell.' },
|
||||
insights: { title: 'Einblicke', description: 'Notizen zur Sicherheit privater 5G-Netze, kritischer Netze und der Systeme, die sie betreiben.' },
|
||||
imprint: { title: 'Impressum', description: 'Impressum der CipherCell-Website.' },
|
||||
privacy: { title: 'Datenschutz', description: 'Datenschutzerklärung der CipherCell-Website.' },
|
||||
},
|
||||
nav: {
|
||||
skip: 'Zum Inhalt springen',
|
||||
capabilities: 'Leistungen',
|
||||
sectors: 'Einsatzfelder',
|
||||
insights: 'Einblicke',
|
||||
team: 'Team',
|
||||
contact: 'Kontakt',
|
||||
menu: 'Menü',
|
||||
close: 'Schließen',
|
||||
homeLabel: 'CipherCell, Startseite',
|
||||
},
|
||||
hero: {
|
||||
lines: ['Sicherheit für die Netze,', 'auf denen alles andere läuft.'],
|
||||
imageAlt: 'Eine Industrieanlage in der Dämmerung: Rohrbrücken, Tanks und ein Gittermast mit 5G-Antennen unter bedecktem Himmel.',
|
||||
},
|
||||
manifesto: {
|
||||
lines: ['Sicherheit wird eingebaut,', 'nicht angeschraubt.'],
|
||||
p1: 'Private 5G-Netze, industrielle Steuerungssysteme und Cloud-Plattformen werden aus Komponenten vieler Hersteller zusammengesetzt. Jede Schnittstelle dazwischen ist eine Angriffsfläche, und Standardwerkzeuge sind dafür nicht gebaut.',
|
||||
p2: 'CipherCell ist ein Security-Engineering-Unternehmen. Wir modellieren reale Angreifer, verankern Zero Trust in der Architektur statt daneben und weisen nach, dass sie standhält. Vom ersten Bedrohungsmodell bis zum laufenden System.',
|
||||
cta: 'Über CipherCell',
|
||||
},
|
||||
method: {
|
||||
lines: ['Vier Stufen.', 'Eine Methode.'],
|
||||
body: 'Bedrohungsanalyse, Engineering, Verifikation und Monitoring, als eine durchgehende Methode statt vier getrennter Projekte. Modular, integrierbar und gebaut für den operativen Alltag.',
|
||||
imageAlt: 'Eine Sicherheitsingenieurin hinter Glas, die Spiegelungen von Monitoring-Bildschirmen liegen über ihrem Gesicht.',
|
||||
diagramTitle: 'Die CipherCell-Methode',
|
||||
diagramDesc: 'Ein geschlossener Kreislauf aus vier Stufen: Bedrohungsanalyse, Engineering, Verifikation und Monitoring. Jede Stufe hat ihre Instrumente: Bedrohungsmodell, Angriffspfade und CI/CD-Integration; Zero Trust, Schlüsselmanagement und sichere APIs; unabhängige Prüfung, Härtung und Tests; Angriffserkennung, Security-Logging und Resilienzprüfung. Das Monitoring fließt zurück in die Bedrohungsanalyse.',
|
||||
stages: [
|
||||
{ title: 'Bedrohungsanalyse', items: ['Bedrohungsmodell', 'Angriffspfade', 'CI/CD-Integration'] },
|
||||
{ title: 'Engineering', items: ['Zero Trust', 'Schlüsselmanagement', 'Sichere APIs'] },
|
||||
{ title: 'Verifikation', items: ['Unabhängige Prüfung', 'Härtung', 'Tests'] },
|
||||
{ title: 'Monitoring', items: ['Angriffserkennung', 'Security-Logging', 'Resilienzprüfung'] },
|
||||
],
|
||||
},
|
||||
stages: {
|
||||
eyebrow: 'Was wir tun.',
|
||||
items: [
|
||||
{ title: 'Bedrohungs\u00ADanalyse', body: 'Automatisierte, empirische Analyse realer Angriffspfade. Sie läuft in Ihrer CI/CD-Pipeline und liefert kontinuierliche Absicherung.' },
|
||||
{ title: 'Engineering', body: 'Zero-Trust-Durchsetzung, Schlüsselmanagement und sicheres API-Design als modulare, kombinierbare Werkzeuge für komplexe Deployments.' },
|
||||
{ title: 'Verifikation', body: 'Unabhängige Prüfung von Sicherheitsmaßnahmen, Härtungs-Checklisten und anwendungsnahe Tests über alle Komponenten.' },
|
||||
{ title: 'Monitoring', body: 'Angriffserkennung und Security-Logging, vom ersten Deployment bis zur laufenden Resilienzprüfung.' },
|
||||
],
|
||||
},
|
||||
sectors: {
|
||||
title: 'Für wen wir arbeiten',
|
||||
items: [
|
||||
{ title: 'Private 5G- und Campusnetze', body: 'Disaggregierte Netze mit offenen Schnittstellen und einer Lieferkette aus vielen Herstellern. Unser Ursprung, und noch immer der schwerste Fall.', imageAlt: 'Eine private 5G-Antenne mit Funkschrank auf der Attika eines Fabrikdachs, Kräne im Hintergrund.' },
|
||||
{ title: 'Kritische Netzinfrastruktur', body: 'Netze, in denen ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' },
|
||||
{ title: 'Cloud-native Systeme', body: 'Container, Service Meshes und CI/CD-Pipelines, die Infrastruktur tragen. Sicherheit als Code, neben Ihrem.', imageAlt: 'Ein Kaltgang zwischen Serverschränken in einem Rechenzentrum, ein Techniker weit hinten.' },
|
||||
],
|
||||
},
|
||||
proof: {
|
||||
title: 'Woher wir kommen.',
|
||||
items: [
|
||||
{ name: 'Universität Passau', body: 'Unsere Methode ist aus akademischer Sicherheitsforschung entstanden. Die Universität bleibt unser Forschungspartner.' },
|
||||
{ name: 'xG-ALOE-Allianz und xG-Incubator', body: 'Partner in der Allianz für offene Unternehmensnetze, vom Labor bis zum Betrieb.' },
|
||||
{ name: 'Bundesministerium für Forschung, Technologie und Raumfahrt', body: 'Unsere Arbeit wird öffentlich gefördert.' },
|
||||
],
|
||||
},
|
||||
quote: {
|
||||
eyebrow: 'Aus dem Essay',
|
||||
text: 'Standardkonform ist nicht dasselbe wie sicher.',
|
||||
name: 'Dr. Felix Klement',
|
||||
role: 'Gründer und CEO',
|
||||
link: 'Essay lesen',
|
||||
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
|
||||
},
|
||||
insights: {
|
||||
title: 'Einblicke',
|
||||
all: 'Alle Einblicke',
|
||||
intro: 'Notizen zur Sicherheit privater 5G-Netze, kritischer Netze und der Systeme, die sie betreiben.',
|
||||
readingTime: (min: number) => `${min} Min. Lesezeit`,
|
||||
event: { category: 'Veranstaltung', when: 'September 2026', text: 'Treffen Sie uns beim xG‑ALOE Plugfest 2026.', link: 'Zur Veranstaltung', href: 'https://xg-aloe.de/xg-aloe-plugfest-2026/' },
|
||||
back: 'Alle Einblicke',
|
||||
by: 'Von',
|
||||
inEnglish: 'Dieser Essay liegt auf Englisch vor.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Sprechen Sie mit uns.',
|
||||
body: 'Komplexe Infrastruktur, eine knappe Compliance-Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.',
|
||||
address: ['Innstr. 43', '94032 Passau, Deutschland'],
|
||||
phone: '+49 (0)851 / 509-3218',
|
||||
phoneHref: 'tel:+498515093218',
|
||||
email: 'office@ciphercell.de',
|
||||
form: {
|
||||
firstName: 'Vorname',
|
||||
lastName: 'Nachname',
|
||||
email: 'E-Mail',
|
||||
message: 'Nachricht',
|
||||
required: 'Pflichtfeld.',
|
||||
honeypot: 'Dieses Feld bitte leer lassen',
|
||||
submit: 'Nachricht senden',
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
mission: 'Security Engineering für Infrastruktur, in der Ausfälle Folgen haben.',
|
||||
company: 'Unternehmen',
|
||||
capabilities: 'Leistungen',
|
||||
contact: 'Kontakt',
|
||||
legal: 'Rechtliches',
|
||||
language: 'Sprache',
|
||||
imprint: 'Impressum',
|
||||
privacy: 'Datenschutz',
|
||||
github: 'GitHub',
|
||||
email: 'E-Mail',
|
||||
fundingAlt: 'Gefördert vom Bundesministerium für Forschung, Technologie und Raumfahrt.',
|
||||
copyright: (year: number) => `© ${year} CipherCell`,
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
},
|
||||
team: {
|
||||
title: ['Forschende,', 'Ingenieure,', 'Architekten.'],
|
||||
intro: 'Die Menschen, die sich in die Probleme vertiefen, auf die es ankommt: von 5G und privaten Netzen bis zu Hardware, Software und Cloud-nativen Systemen.',
|
||||
photoAlt: 'Das CipherCell-Team auf einer Treppe in den Büros in Passau.',
|
||||
links: { github: 'GitHub', linkedin: 'LinkedIn', email: 'E-Mail' },
|
||||
roles: {
|
||||
ceo: 'Gründer und CEO',
|
||||
cto: 'Gründer und CTO',
|
||||
engineer: 'Software Engineer',
|
||||
bizdev: 'Business Development',
|
||||
studentF: 'Werkstudentin, Entwicklung',
|
||||
studentM: 'Werkstudent, Entwicklung',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Seite nicht gefunden',
|
||||
body: 'Die Adresse, der Sie gefolgt sind, führt ins Leere. Die vorhandenen Seiten stehen in der Fußzeile.',
|
||||
link: 'Zur Startseite',
|
||||
},
|
||||
legal: {
|
||||
imprintEyebrow: 'Angaben gemäß § 5 TMG',
|
||||
responsible: 'Verantwortlich',
|
||||
mailing: 'Postanschrift',
|
||||
contact: 'Kontakt',
|
||||
asOf: 'Stand: 14. Juni 2025',
|
||||
},
|
||||
};
|
||||
|
||||
export const ui: Record<Lang, typeof en> = { en, de };
|
||||
export function t(lang: Lang) { return ui[lang]; }
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import '../styles/base.css';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { langFromUrl, switchLang, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
lang?: Lang;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
|
||||
const { title, description } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
const altEn = new URL(switchLang(Astro.url, 'en'), Astro.site);
|
||||
const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#E9E5DF" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="alternate" hreflang="en" href={altEn} />
|
||||
<link rel="alternate" hreflang="de" href={altDe} />
|
||||
<link rel="alternate" hreflang="x-default" href={altEn} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={ui.meta.siteName} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content={lang === 'de' ? 'de_DE' : 'en_GB'} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">{ui.nav.skip}</a>
|
||||
<Header lang={lang} />
|
||||
<main id="main" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer lang={lang} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import NotFound from './_NotFound.astro';
|
||||
---
|
||||
<NotFound lang="en" />
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
import Manifesto from '../components/home/Manifesto.astro';
|
||||
import Method from '../components/home/Method.astro';
|
||||
import Stages from '../components/home/Stages.astro';
|
||||
import Sectors from '../components/home/Sectors.astro';
|
||||
import Proof from '../components/home/Proof.astro';
|
||||
import Quote from '../components/home/Quote.astro';
|
||||
import Insights from '../components/home/Insights.astro';
|
||||
import Contact from '../components/home/Contact.astro';
|
||||
import { t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description}>
|
||||
<Hero lang={lang} />
|
||||
<Manifesto lang={lang} />
|
||||
<Method lang={lang} />
|
||||
<Stages lang={lang} />
|
||||
<Sectors lang={lang} />
|
||||
<Proof lang={lang} />
|
||||
<Quote lang={lang} />
|
||||
<Insights lang={lang} />
|
||||
<Contact lang={lang} />
|
||||
</Base>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { formatDate, 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());
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.insights.title} description={ui.meta.insights.description}>
|
||||
<section class="section section--intro">
|
||||
<div class="container grid">
|
||||
<h1 class="display index__title">{ui.insights.title}</h1>
|
||||
<p class="index__lead">{ui.insights.intro}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section--list" aria-label={ui.insights.title}>
|
||||
<div class="container">
|
||||
<ul class="posts" role="list">
|
||||
{posts.map((post) => (
|
||||
<li class="post">
|
||||
<a class="post__link" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
|
||||
<div class="post__media">
|
||||
<Picture
|
||||
src={post.data.cover}
|
||||
alt={post.data.coverAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[400, 640, 960]}
|
||||
sizes="(min-width: 48rem) 40vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="post__img img-doc"
|
||||
/>
|
||||
</div>
|
||||
<div class="post__text">
|
||||
<p class="small muted post__meta">
|
||||
<span>{post.data.category}</span>
|
||||
<time datetime={post.data.date.toISOString().slice(0, 10)}>{formatDate(post.data.date, lang)}</time>
|
||||
<span>{ui.insights.readingTime(post.data.readingTime)}</span>
|
||||
</p>
|
||||
<h2 class="h2">{post.data.title}</h2>
|
||||
{post.data.subtitle && <p class="muted">{post.data.subtitle}</p>}
|
||||
{post.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index__title { grid-column: 1 / -1; }
|
||||
.index__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
|
||||
@media (min-width: 48rem) {
|
||||
.index__title { grid-column: 1 / span 8; }
|
||||
.index__lead { grid-column: 1 / span 5; }
|
||||
}
|
||||
.section--list { padding-bottom: var(--section); }
|
||||
.posts { border-top: var(--hairline) solid var(--line); }
|
||||
.post { border-bottom: var(--hairline) solid var(--line); }
|
||||
.post__link {
|
||||
display: grid;
|
||||
gap: var(--space-5) var(--gap);
|
||||
padding-block: var(--space-7);
|
||||
text-decoration: none;
|
||||
}
|
||||
.post__link:hover h2 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; }
|
||||
.post__img, :global(.post__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }
|
||||
.post__text { display: grid; gap: var(--space-4); align-content: start; }
|
||||
.post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
||||
@media (min-width: 48rem) {
|
||||
.post__link { grid-template-columns: 5fr 7fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import type { Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang; title: string; description: string; eyebrow?: string; lead?: string }
|
||||
const { lang, title, description, eyebrow, lead } = Astro.props;
|
||||
---
|
||||
<Base lang={lang} title={title} description={description}>
|
||||
<section class="section">
|
||||
<div class="container grid">
|
||||
<div class="legal__head">
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h1 class="h2">{title}</h1>
|
||||
{lead && <p class="muted legal__lead">{lead}</p>}
|
||||
</div>
|
||||
<div class="prose legal__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal__head { grid-column: 1 / -1; display: grid; gap: var(--space-4); align-content: start; }
|
||||
.legal__lead { max-width: var(--measure); }
|
||||
.legal__body { grid-column: 1 / -1; }
|
||||
@media (min-width: 48rem) {
|
||||
.legal__head { grid-column: 1 / span 4; }
|
||||
.legal__body { grid-column: 6 / span 7; }
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.legal__head { grid-column: 1 / span 4; }
|
||||
.legal__body { grid-column: 6 / span 6; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { localePath, t, type Lang } from '../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.notFound.title} description={ui.notFound.body}>
|
||||
<section class="section">
|
||||
<div class="container grid">
|
||||
<h1 class="display nf__title">{ui.notFound.title}</h1>
|
||||
<div class="nf__body">
|
||||
<p class="muted">{ui.notFound.body}</p>
|
||||
<a class="link" href={localePath(lang, '/')}>{ui.notFound.link}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.nf__title { grid-column: 1 / -1; }
|
||||
.nf__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); }
|
||||
@media (min-width: 48rem) {
|
||||
.nf__title { grid-column: 1 / span 8; }
|
||||
.nf__body { grid-column: 1 / span 5; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { render } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang; post: CollectionEntry<'insights'> }
|
||||
const { lang, post } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const { Content } = await render(post);
|
||||
const d = post.data;
|
||||
---
|
||||
<Base lang={lang} title={d.title} description={d.description}>
|
||||
<article class="post" lang={d.lang}>
|
||||
<header class="section section--intro post__head">
|
||||
<div class="container grid">
|
||||
<div class="post__meta-col">
|
||||
<a class="link" href={localePath(lang, '/insights')} lang={lang}>{ui.insights.back}</a>
|
||||
</div>
|
||||
<div class="post__title-col">
|
||||
<p class="small muted post__meta">
|
||||
<span>{d.category}</span>
|
||||
<time datetime={d.date.toISOString().slice(0, 10)}>{formatDate(d.date, lang)}</time>
|
||||
<span lang={lang}>{ui.insights.readingTime(d.readingTime)}</span>
|
||||
</p>
|
||||
<h1 class="h2 post__title">{d.title}</h1>
|
||||
{d.subtitle && <p class="post__subtitle muted">{d.subtitle}</p>}
|
||||
<p class="post__author"><span lang={lang}>{ui.insights.by}</span> <span class="post__author-name">{d.author}</span><span class="muted">, {d.authorRole}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<Picture
|
||||
src={d.cover}
|
||||
alt={d.coverAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[640, 960, 1440, 1920]}
|
||||
sizes="(min-width: 90rem) 1440px, 100vw"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="post__cover img-doc"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section container grid">
|
||||
<div class="prose post__body">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.post__meta-col { grid-column: 1 / -1; }
|
||||
.post__title-col { grid-column: 1 / -1; display: grid; gap: var(--space-5); }
|
||||
.post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
||||
.post__title { text-wrap: balance; }
|
||||
.post__subtitle { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); max-width: var(--measure); }
|
||||
.post__author-name { font-weight: var(--weight-medium); }
|
||||
.post__cover, :global(.post__cover) { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; object-position: 50% 12%; }
|
||||
.post__body { grid-column: 1 / -1; }
|
||||
@media (min-width: 48rem) {
|
||||
.post__meta-col { grid-column: 1 / span 2; }
|
||||
.post__title-col { grid-column: 3 / span 9; }
|
||||
.post__body { grid-column: 3 / span 8; }
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.post__title-col { grid-column: 3 / span 8; }
|
||||
.post__body { grid-column: 3 / span 7; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import teamPhoto from '../assets/images/team.png';
|
||||
import { team } from '../data/team';
|
||||
import { t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.team.title} description={ui.meta.team.description}>
|
||||
<section class="section section--intro team-intro">
|
||||
<div class="container grid">
|
||||
<h1 class="display team-intro__title">
|
||||
{ui.team.title.map((line) => <span class="line">{line}</span>)}
|
||||
</h1>
|
||||
<p class="team-intro__lead">{ui.team.intro}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="team-photo">
|
||||
<Picture
|
||||
src={teamPhoto}
|
||||
alt={ui.team.photoAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[640, 960, 1440]}
|
||||
sizes="100vw"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="img-cover img-mono team-photo__img"
|
||||
pictureAttributes={{ class: 'team-photo__picture' }}
|
||||
/>
|
||||
<div class="team-photo__overlay" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<section class="section" aria-label={ui.nav.team}>
|
||||
<div class="container">
|
||||
<ul class="people" role="list">
|
||||
{team.map((p) => (
|
||||
<li class="person">
|
||||
<Picture
|
||||
src={p.portrait}
|
||||
alt={p.name}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[320, 480, 640]}
|
||||
sizes="(min-width: 64rem) 22vw, (min-width: 48rem) 45vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="img-mono img-print person__img"
|
||||
/>
|
||||
<h2 class="h3 person__name">{p.name}</h2>
|
||||
<p class="muted">{ui.team.roles[p.role]}</p>
|
||||
<ul class="person__links small" role="list">
|
||||
{p.github && <li><a href={p.github} rel="noopener">{ui.team.links.github}</a></li>}
|
||||
{p.linkedin && <li><a href={p.linkedin} rel="noopener">{ui.team.links.linkedin}</a></li>}
|
||||
<li><a href={`mailto:${p.email}`}>{ui.team.links.email}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.line { display: block; }
|
||||
.team-intro__title { grid-column: 1 / -1; }
|
||||
.team-intro__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
|
||||
@media (min-width: 48rem) {
|
||||
.team-intro__title { grid-column: 1 / span 7; }
|
||||
.team-intro__lead { grid-column: 9 / span 4; align-self: end; }
|
||||
}
|
||||
|
||||
.team-photo {
|
||||
position: relative;
|
||||
/* Explicit height: an aspect-ratio with max-height would transfer the cap to the width. */
|
||||
height: min(66.67vw, 80svh);
|
||||
min-height: 20rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.team-photo__picture) { position: absolute; inset: 0; }
|
||||
.team-photo__img, :global(.team-photo__img) { width: 100%; height: 100%; object-fit: cover; object-position: 50% 30%; }
|
||||
.team-photo__overlay { position: absolute; inset: 0; background: var(--overlay); opacity: 0.6; }
|
||||
|
||||
.people {
|
||||
display: grid;
|
||||
gap: var(--space-8) var(--gap);
|
||||
}
|
||||
.person { display: grid; gap: var(--space-2); align-content: start; }
|
||||
.person__img, :global(.person__img) { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; margin-bottom: var(--space-2); }
|
||||
.person__links { display: flex; gap: var(--space-4); margin-top: var(--space-2); }
|
||||
.person__links a { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
|
||||
|
||||
@media (min-width: 30rem) { .people { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (min-width: 64rem) { .people { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Legal from '../_Legal.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
const ui = t('de');
|
||||
---
|
||||
<Legal lang="de" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}>
|
||||
<h2>Verantwortlich</h2>
|
||||
<p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Deutschland</p>
|
||||
<p>Telefon: <a href="tel:+498515093218">+49 851 509 3218</a><br />E-Mail: <a href="mailto:felix.klement@ciphercell.de">felix.klement@ciphercell.de</a></p>
|
||||
|
||||
<h2>Inhalt der Website</h2>
|
||||
<p>CipherCell (der Webseitenbetreiber) prüft und aktualisiert die Informationen auf seinen Webseiten regelmäßig. Trotz aller Sorgfalt übernimmt der Webseitenbetreiber keine Haftung oder Garantie für die Aktualität, Richtigkeit, Vollständigkeit oder Qualität der online verfügbaren Informationen. Alle Angebote sind freibleibend und unverbindlich. Der Webseitenbetreiber behält es sich ausdrücklich vor, einzelne Seiten oder das gesamte Onlineangebot ohne vorhergehende Ankündigung zu verändern, zu ergänzen, zu löschen oder das Angebot auf bestimmte Zeit oder auch endgültig einzustellen.</p>
|
||||
|
||||
<h2>Verweise und Links</h2>
|
||||
<p>CipherCell haftet nicht für den Inhalt von fremden Webseiten, auf die im Onlineangebot von CipherCell z. B. mittels Hyperlinks verwiesen wird. Dies gilt auch für alle Einträge Dritter in vom Webseitenbetreiber eingerichteten Gästebüchern, Diskussionsforen, Pinnwänden und Mailinglisten.</p>
|
||||
|
||||
<h2>Urheber- und Kennzeichenrecht</h2>
|
||||
<p>Inhalt und Struktur der Webseite von CipherCell sind urheberrechtlich geschützt. Die Vervielfältigung von Grafiken, Tondokumenten, Bildmaterialien, Film- und Videoausschnitten und Texten in anderen elektronischen oder gedruckten Medien und Publikationen ist ohne die ausdrückliche Erlaubnis von CipherCell nicht gestattet.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Home from '../_Home.astro';
|
||||
---
|
||||
<Home lang="de" />
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../../_Post.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('insights');
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
<Post lang="de" post={post} />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import InsightsIndex from '../../_InsightsIndex.astro';
|
||||
---
|
||||
<InsightsIndex lang="de" />
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import Legal from '../_Legal.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
const ui = t('de');
|
||||
---
|
||||
<Legal lang="de" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell nimmt die Belange des Datenschutzes ernst und erhebt, verarbeitet und nutzt Ihre personenbezogenen Daten ausschließlich in Übereinstimmung mit den nachfolgend beschriebenen Grundsätzen und unter Beachtung der anwendbaren Datenschutzgesetze.">
|
||||
<h2>Verantwortliche Stelle</h2>
|
||||
<p>Die verantwortliche Stelle für den Betrieb der CipherCell-Website (im Folgenden „Website“) sowie die Erhebung, Verarbeitung und Nutzung Ihrer personenbezogenen Daten im Sinne des anwendbaren Datenschutzrechts ist Dr. Felix Klement, Innstr. 43, 94032 Passau, Deutschland.</p>
|
||||
|
||||
<h2>Webanalyse mit Matomo (ohne Cookies)</h2>
|
||||
<p>Wir verwenden auf unserer Website die Open-Source-Webanalysesoftware Matomo, um das Nutzungsverhalten auf datenschutzfreundliche Weise auszuwerten und unsere Website technisch und inhaltlich zu verbessern. Dabei haben wir Matomo so konfiguriert, dass keine Cookies gesetzt werden und Ihre IP-Adresse anonymisiert gespeichert wird. Es werden keine personenbezogenen Daten verarbeitet, und es erfolgt keine Weitergabe an Dritte. Die Verarbeitung erfolgt auf Grundlage unseres berechtigten Interesses gemäß Art. 6 Abs. 1 lit. f DSGVO an der statistischen Analyse des Nutzerverhaltens zur Optimierung unseres Webangebots. Wenn Ihr Browser die „Do Not Track“-Funktion unterstützt und aktiviert ist, wird Ihr Besuch von Matomo automatisch ignoriert. Sie können der anonymisierten Analyse Ihres Besuchs dennoch jederzeit widersprechen. Nutzen Sie dazu bitte den folgenden Opt-out.</p>
|
||||
<p><a href="https://cipher.uber.space/matomo/index.php?module=CoreAdminHome&action=optOut&language=de" rel="noopener">Matomo Opt-out</a></p>
|
||||
|
||||
<h2>Datensicherheit</h2>
|
||||
<p>Ihre Nutzerdaten sind bei der Übermittlung auf unsere Server durch den Einsatz von TLS in Verbindung mit einer 256-Bit-Verschlüsselung geschützt. Sie können die Sicherheit der Verbindung anhand der Angaben in der Adresszeile Ihres Browsers prüfen. Beginnt die Adresszeile mit „https“, liegt eine gesicherte Verbindung vor.</p>
|
||||
|
||||
<h2>Kontaktaufnahme</h2>
|
||||
<p>Wenn Sie uns per E-Mail oder auf anderem Wege kontaktieren, speichern wir Ihre übermittelten personenbezogenen Daten (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht), um Ihre Anfrage zu bearbeiten. Diese Daten werden ausschließlich für die Kommunikation mit Ihnen verwendet und nicht an Dritte weitergegeben. Rechtsgrundlage für die Verarbeitung der Daten ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertragsverhältnisses) oder Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bearbeitung der Anfrage).</p>
|
||||
|
||||
<h2>Recht auf Auskunft, Berichtigung, Sperrung oder Löschung</h2>
|
||||
<p>Nach dem anwendbaren Datenschutzrecht haben Sie ein Recht auf unentgeltliche Auskunft über Ihre gespeicherten Daten sowie ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Wenden Sie sich bei weitergehenden Fragen zum Datenschutz und zur Verarbeitung Ihrer personenbezogenen Daten, bei Auskünften oder zur Ausübung Ihres Rechts auf Berichtigung, Sperrung oder Löschung oder für den Fall des Widerrufs einer Einwilligung bitte per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. Selbstverständlich können Sie auch die im Impressum angegebenen Kontaktdaten oder das Kontaktformular auf der Website nutzen. CipherCell verarbeitet und nutzt die von Ihnen mitgeteilten Informationen ausschließlich für die Bearbeitung Ihres Anliegens.</p>
|
||||
|
||||
<h2>Anpassungen</h2>
|
||||
<p>CipherCell behält sich vor, diese Datenschutzbestimmungen von Zeit zu Zeit anzupassen. Wir werden Sie von jeder Änderung oder Ergänzung vorab rechtzeitig unterrichten und, soweit erforderlich, Ihre Einwilligung in die geänderte Verarbeitung oder Nutzung Ihrer personenbezogenen Daten einholen.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Team from '../_Team.astro';
|
||||
---
|
||||
<Team lang="de" />
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Legal from './_Legal.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
const ui = t('en');
|
||||
---
|
||||
<Legal lang="en" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}>
|
||||
<h2>Responsible</h2>
|
||||
<p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Germany</p>
|
||||
<p>Telephone: <a href="tel:+498515093218">+49 851 509 3218</a><br />Email: <a href="mailto:felix.klement@ciphercell.de">felix.klement@ciphercell.de</a></p>
|
||||
|
||||
<h2>Website content</h2>
|
||||
<p>CipherCell (the website operator) regularly reviews and updates the information on its websites. Despite all due care, the website operator accepts no liability or guarantee for the timeliness, accuracy, completeness, or quality of the information available online. All offers are subject to change and non-binding. The website operator expressly reserves the right to change, supplement, delete or discontinue the offer at any time without prior notice. The website operator is not responsible for the content of external links.</p>
|
||||
|
||||
<h2>References and links</h2>
|
||||
<p>CipherCell is not liable for the content of external websites referred to in CipherCell's online offering, e.g. via hyperlinks. This also applies to all entries by third parties in guest books, discussion forums, message boards, and mailing lists set up by the website operator.</p>
|
||||
|
||||
<h2>Copyright and trademark law</h2>
|
||||
<p>The content and structure of the CipherCell website are protected by copyright. The reproduction of graphics, audio documents, image material, film and video excerpts, and texts in other electronic or printed media and publications is not permitted without the express permission of CipherCell. The use of trademarks and logos is also subject to the provisions of the applicable trademark law.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Home from './_Home.astro';
|
||||
---
|
||||
<Home lang="en" />
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../_Post.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('insights');
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
<Post lang="en" post={post} />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import InsightsIndex from '../_InsightsIndex.astro';
|
||||
---
|
||||
<InsightsIndex lang="en" />
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import Legal from './_Legal.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
const ui = t('en');
|
||||
---
|
||||
<Legal lang="en" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell takes data protection seriously and collects, processes and uses your personal data exclusively in accordance with the principles described below and in compliance with applicable data protection law.">
|
||||
<h2>Responsible body</h2>
|
||||
<p>The responsible body for the operation of the CipherCell website (hereinafter referred to as "website") and the collection, processing, and use of your personal data within the meaning of applicable data protection law is Dr. Felix Klement, Innstr. 43, 94032 Passau, Germany.</p>
|
||||
|
||||
<h2>Web analytics with Matomo (without cookies)</h2>
|
||||
<p>We use the open source web analytics software Matomo on our website to evaluate user behavior in a privacy-friendly manner and to improve our website in terms of technology and content. We have configured Matomo so that no cookies are set and your IP address is stored anonymously. No personal data is processed and no data is passed on to third parties. Processing is based on our legitimate interest pursuant to Art. 6 (1) lit. f GDPR in the statistical analysis of user behavior for the purpose of optimizing our website. If your browser supports and has activated the "Do Not Track" function, your visit will be automatically ignored by Matomo. You can still object to the anonymized analysis of your visit at any time. To do so, please use the opt-out below.</p>
|
||||
<p><a href="https://cipher.uber.space/matomo/index.php?module=CoreAdminHome&action=optOut&language=en" rel="noopener">Matomo opt-out</a></p>
|
||||
|
||||
<h2>Data security</h2>
|
||||
<p>Your user data is protected during transmission to our servers through the use of TLS in conjunction with 256-bit encryption. You can check the security of the connection by looking at the information in your browser's address bar. If the beginning of the address line reads "https", the connection is secure.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>If you contact us by email or other means, we will store the personal data you provide (e.g. name, email address, content of the message) in order to process your request. This data will be used exclusively for communication with you and will not be passed on to third parties. The legal basis for processing the data is Art. 6 para. 1 lit. b GDPR (initiation of a contractual relationship) or Art. 6 para. 1 lit. f GDPR (legitimate interest in processing the request).</p>
|
||||
|
||||
<h2>Right to information, correction, blocking, or deletion</h2>
|
||||
<p>Under applicable data protection law, you have the right to obtain information about your stored data free of charge and, if necessary, the right to correct, block, or delete this data. If you have any further questions about data protection and the processing of your personal data, if you require information, or if you wish to exercise your right to correction, blocking, or deletion, or in the event of revocation of consent, please send an email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. You can also use the contact details provided in the legal notice or the contact form on the website. CipherCell processes and uses the information you provide exclusively for the purpose of processing your request.</p>
|
||||
|
||||
<h2>Adjustments</h2>
|
||||
<p>CipherCell reserves the right to amend these data protection provisions from time to time. We will inform you in advance of any changes or additions and, where necessary, obtain your consent to the amended processing or use of your personal data.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Team from './_Team.astro';
|
||||
---
|
||||
<Team lang="en" />
|
||||
@@ -0,0 +1,214 @@
|
||||
@import './tokens.css';
|
||||
|
||||
/* Self-hosted Inter Tight, variable, latin + latin-ext. */
|
||||
@font-face {
|
||||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter-tight-latin-wght-normal.woff2') format('woff2-variations');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter-tight-latin-ext-wght-normal.woff2') format('woff2-variations');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
* { margin: 0; }
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
[id] { scroll-margin-top: var(--header-height); }
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--step-body);
|
||||
line-height: var(--leading-body);
|
||||
font-weight: var(--weight-regular);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: 'ss01' off, 'cv11' on;
|
||||
}
|
||||
img, picture, svg, video { display: block; max-width: 100%; }
|
||||
img { height: auto; }
|
||||
input, button, textarea, select { font: inherit; color: inherit; }
|
||||
button { background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
ul[role='list'], ol[role='list'] { list-style: none; padding: 0; }
|
||||
a { color: inherit; text-decoration-color: currentColor; text-underline-offset: 0.14em; text-decoration-thickness: from-font; }
|
||||
h1, h2, h3, h4 { font-weight: var(--weight-medium); overflow-wrap: break-word; }
|
||||
p { overflow-wrap: break-word; }
|
||||
|
||||
/* Focus: one ring, everywhere. */
|
||||
:focus-visible {
|
||||
outline: var(--focus-width) solid currentColor;
|
||||
outline-offset: var(--focus-offset);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:focus:not(:focus-visible) { outline: none; }
|
||||
|
||||
/* Type scale */
|
||||
.display {
|
||||
font-size: var(--step-display);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-display);
|
||||
line-height: var(--leading-display);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.h2 {
|
||||
font-size: var(--step-h2);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-h2);
|
||||
line-height: var(--leading-h2);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.h3 {
|
||||
font-size: var(--step-h3);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-h3);
|
||||
line-height: var(--leading-h3);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.body { font-size: var(--step-body); line-height: var(--leading-body); }
|
||||
.small {
|
||||
font-size: var(--step-small);
|
||||
line-height: var(--leading-small);
|
||||
}
|
||||
.muted { color: var(--ink-muted); }
|
||||
|
||||
/* An eyebrow is sentence case, muted, small. Never all caps. */
|
||||
.eyebrow {
|
||||
font-size: var(--step-small);
|
||||
line-height: var(--leading-small);
|
||||
color: var(--ink-muted);
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
|
||||
/* Primitives */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--container);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
column-gap: var(--gap);
|
||||
row-gap: var(--space-7);
|
||||
}
|
||||
.section {
|
||||
padding-block: var(--section);
|
||||
}
|
||||
.section--tight { padding-block: calc(var(--section) * 0.6); }
|
||||
/* A page head: full air above, less below, so the content follows the title. */
|
||||
.section--intro { padding-block: var(--section) calc(var(--section) * 0.4); }
|
||||
.measure { max-width: var(--measure); }
|
||||
|
||||
/* Night surface */
|
||||
.night {
|
||||
background: var(--night);
|
||||
color: var(--ink-on-night);
|
||||
--ink: var(--ink-on-night);
|
||||
--ink-muted: var(--ink-muted-on-night);
|
||||
--line: var(--line-on-night);
|
||||
}
|
||||
|
||||
/* Hairline rules carry information: they separate, they don't decorate. */
|
||||
.rule { border: 0; border-top: var(--hairline) solid var(--line); }
|
||||
|
||||
/* Links and buttons work through underline, weight and surface. */
|
||||
.link {
|
||||
display: inline-block;
|
||||
font-weight: var(--weight-medium);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: var(--hairline);
|
||||
text-underline-offset: 0.2em;
|
||||
transition: text-underline-offset var(--dur-fast) var(--ease);
|
||||
}
|
||||
.link:hover { text-underline-offset: 0.3em; }
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font-weight: var(--weight-medium);
|
||||
text-decoration: none;
|
||||
line-height: var(--leading-small);
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.button:hover { background: var(--ink-muted); }
|
||||
.button--small { padding: var(--space-2) var(--space-4); font-size: var(--step-small); }
|
||||
.night .button { background: var(--paper); color: var(--night); }
|
||||
.night .button:hover { background: var(--paper-deep); }
|
||||
|
||||
/* Prose: long-form text */
|
||||
.prose { max-width: var(--measure); }
|
||||
.prose > * + * { margin-top: var(--space-5); }
|
||||
.prose h2 {
|
||||
font-size: var(--step-h3);
|
||||
letter-spacing: var(--track-h3);
|
||||
line-height: var(--leading-h3);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
.prose h3 { font-size: var(--step-body); margin-top: var(--space-7); }
|
||||
.prose ul, .prose ol { padding-left: 1.2em; }
|
||||
.prose li + li { margin-top: var(--space-3); }
|
||||
.prose a { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
|
||||
.prose strong { font-weight: var(--weight-medium); }
|
||||
.prose blockquote {
|
||||
padding-left: var(--space-5);
|
||||
border-left: var(--hairline) solid var(--line);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.prose img { width: 100%; }
|
||||
.prose figcaption { margin-top: var(--space-3); font-size: var(--step-small); color: var(--ink-muted); }
|
||||
|
||||
/* Images */
|
||||
.img-cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
.img-doc { filter: saturate(0.55) contrast(1.02); }
|
||||
.img-mono { filter: grayscale(1) contrast(1.04); }
|
||||
/* Portraits were shot on light grey; multiply lets the paper carry them like a print. */
|
||||
.img-print { mix-blend-mode: multiply; }
|
||||
|
||||
/* Skip link */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: var(--gutter);
|
||||
top: var(--space-3);
|
||||
transform: translateY(-200%);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip-link:focus { transform: none; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* Motion off. No exceptions. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Design tokens. The only place where colours, sizes and durations are defined.
|
||||
Components consume these; a hard-coded hex or px value in a component is a defect. */
|
||||
|
||||
:root {
|
||||
/* Colour: paper, night, image. Nothing else. */
|
||||
--paper: #E9E5DF;
|
||||
--paper-deep: #DFDAD1;
|
||||
--ink: #1A1A18;
|
||||
--ink-muted: #57564F;
|
||||
--night: #0E100F;
|
||||
--line: rgb(26 26 24 / 0.14);
|
||||
|
||||
/* Immutable copies for resetting inside a .night or overlay context */
|
||||
--ink-base: #1A1A18;
|
||||
--ink-muted-base: #57564F;
|
||||
--line-base: rgb(26 26 24 / 0.14);
|
||||
|
||||
/* Counterparts on night surfaces */
|
||||
--ink-on-night: var(--paper);
|
||||
--ink-muted-on-night: rgb(233 229 223 / 0.62);
|
||||
--line-on-night: rgb(233 229 223 / 0.18);
|
||||
|
||||
/* Image overlay */
|
||||
--overlay: linear-gradient(to top, rgb(14 16 15 / 0.75), rgb(14 16 15 / 0.2));
|
||||
|
||||
/* Type */
|
||||
--font-sans: 'Inter Tight', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
--step-display: clamp(2.75rem, 1.2rem + 6.2vw, 7rem);
|
||||
--step-h2: clamp(1.9rem, 1.1rem + 2.6vw, 3.25rem);
|
||||
--step-h3: clamp(1.25rem, 1.05rem + 0.7vw, 1.6rem);
|
||||
/* Card and footer titles: between h2 and h3, for headings that name rather than claim. */
|
||||
--step-title: clamp(1.6rem, 1.2rem + 1.5vw, 2.5rem);
|
||||
--step-body: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
|
||||
--step-small: 0.875rem;
|
||||
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
|
||||
--track-display: -0.03em;
|
||||
--track-h2: -0.02em;
|
||||
--track-h3: -0.01em;
|
||||
|
||||
--leading-display: 1.02;
|
||||
--leading-h2: 1.08;
|
||||
--leading-h3: 1.2;
|
||||
--leading-title: 1.1;
|
||||
--leading-body: 1.55;
|
||||
--leading-small: 1.4;
|
||||
|
||||
--measure: 34em;
|
||||
|
||||
/* Space */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--space-7: 3rem;
|
||||
--space-8: 4rem;
|
||||
--space-9: 6rem;
|
||||
|
||||
--section: clamp(6rem, 12vw, 11rem);
|
||||
--gutter: clamp(1.25rem, 5vw, 5rem);
|
||||
--gap: clamp(1rem, 2vw, 2rem);
|
||||
--container: 90rem; /* 1440px */
|
||||
|
||||
/* Shape */
|
||||
--hairline: 1px;
|
||||
--radius: 0;
|
||||
--radius-sm: 2px;
|
||||
|
||||
/* Motion */
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
--dur-fast: 180ms;
|
||||
--dur: 240ms;
|
||||
--dur-slow: 320ms;
|
||||
--dur-hero: 1400ms;
|
||||
|
||||
/* Chrome */
|
||||
--header-height: 4.5rem;
|
||||
|
||||
/* Focus */
|
||||
--focus-width: 2px;
|
||||
--focus-offset: 3px;
|
||||
}
|
||||