Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6495ad10 | ||
|
|
3cb5952e6d | ||
|
|
a4c2ff3b32 | ||
|
|
656f45c5d2 | ||
|
|
76cedf8400 |
No files matched your search
@@ -74,7 +74,7 @@ Environment; see *Contact form* below.
|
||||
and no third party is involved, so there is nothing to consent to. The form therefore needs
|
||||
JavaScript; without it a `<noscript>` line points to the email address.
|
||||
|
||||
In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `/notify/verify` to the handler on `127.0.0.1:3000`; both are started
|
||||
In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `GET /notify/verify` to the handler on `127.0.0.1:3000`; both are started
|
||||
by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down,
|
||||
visitors are sent to the error page. Without SMTP settings the handler does not start and the
|
||||
rest of the site is served as usual.
|
||||
@@ -98,11 +98,11 @@ Environment; see *Contact form* below.
|
||||
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
|
||||
`list=cipherverifier-alpha`, the honeypot `website` and the proof-of-work answer `altcha` to
|
||||
`POST /notify`, handled by `server/contact.mjs` like the contact form. It is double opt-in
|
||||
without a database: the handler emails the address a link to `/notify/confirm` carrying a
|
||||
signed token (address, language, time of consent; valid 7 days) and keeps nothing. That page
|
||||
posts the token to `POST /notify/verify` with a button (mail scanners open links, they do not
|
||||
press buttons), and the handler sends the confirmed registration, with the consent wording and
|
||||
both times, to `CONTACT_TO`. That mailbox is the list. The privacy page promises deletion
|
||||
without a database: the handler emails the address a link to `GET /notify/verify` carrying a
|
||||
signed token (address, language, time of consent; valid 7 days) and keeps nothing. Opening the
|
||||
link confirms: the handler sends the confirmed registration, with the consent wording and both
|
||||
times, to `CONTACT_TO`, and redirects to `/notify/confirmed`. A mail scanner that opens every
|
||||
link in a message will confirm it too. Old `/notify/confirm` links are redirected by nginx. That mailbox is the list. The privacy page promises deletion
|
||||
after the alpha mail is sent or on withdrawal. The result pages `/notify/confirmed` and
|
||||
`/notify/error` are `noindex` and left out of the sitemap. Set the flag to false to bring back
|
||||
the full product page.
|
||||
@@ -190,7 +190,14 @@ photography existed:
|
||||
| `engineer-glass.jpg` | Method section | https://www.magnific.com/app/creation/3zdScUIREY |
|
||||
| `sector-5g.jpg` | Sectors, tab 1 | https://www.magnific.com/app/creation/s7ZQP52l8e |
|
||||
| `sector-substation.jpg` | Sectors, tab 2 | https://www.magnific.com/app/creation/yi0r27vPW9 |
|
||||
| `sector-datacentre.jpg` | Sectors, tab 3 | https://www.magnific.com/app/creation/eIHAvetdqL |
|
||||
| `sector-datacentre.jpg` | Sectors, tab 5 | https://www.magnific.com/app/creation/eIHAvetdqL |
|
||||
|
||||
`sector-ot.jpg` (Sectors, tab 3, operational technology) is a real photograph from the Freepik
|
||||
stock catalogue via Magnific, "Workers in control room" (free licence, Canon EOS 5D Mark III, 2017),
|
||||
downloaded on 7 October 2026 and scaled to 2304 px.
|
||||
`sector-rd-engineer.jpg` (Sectors, tab 4, products with digital elements) comes from the same
|
||||
catalogue: "Male Industrial Engineer Solving Problems Working on a Personal Computer" (premium licence), downloaded on 7 October 2026 and
|
||||
scaled to 2304 px.
|
||||
|
||||
They are placeholders in the sense of the brief: swap them for the company's own documentary
|
||||
photography when it exists, keeping the same file names and the `img-doc` treatment. The grid
|
||||
|
||||
+14
-2
@@ -51,8 +51,8 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 10s;
|
||||
}
|
||||
# The alpha sign-up, same handler: the form, and the confirmation from the link in its email.
|
||||
location ~ ^/notify(/verify)?$ {
|
||||
# The alpha sign-up, same handler: the form posts here.
|
||||
location = /notify {
|
||||
limit_except POST { deny all; }
|
||||
client_max_body_size 64k;
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
@@ -63,6 +63,18 @@ server {
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = @notify_down;
|
||||
}
|
||||
# The link in the confirmation email; opening it confirms.
|
||||
location = /notify/verify {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = @notify_down;
|
||||
}
|
||||
# Links sent before the confirmation moved off the button page.
|
||||
location ~ ^(/de)?/notify/confirm$ { return 302 /notify/verify$is_args$args; }
|
||||
location @notify_down {
|
||||
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/notify/error; }
|
||||
return 303 /notify/error;
|
||||
|
||||
+15
-12
@@ -9,12 +9,11 @@
|
||||
* A post without a solved challenge is refused, so the form needs scripting; the address next
|
||||
* to it does not.
|
||||
*
|
||||
* Alpha sign-up (POST /notify, POST /notify/verify). Double opt-in without a database: the form
|
||||
* Alpha sign-up (POST /notify, GET /notify/verify). Double opt-in without a database: the form
|
||||
* posts email, consent=yes, list, lang, website and altcha; the handler emails the address a link
|
||||
* to /notify/confirm carrying a signed token (address, language, time of consent, expiry). That
|
||||
* page posts the token back to /notify/verify, which checks the signature and sends the confirmed
|
||||
* registration to CONTACT_TO. The office mailbox is the list. The confirmation is a button, not
|
||||
* the link itself, because mail scanners open every link in a message.
|
||||
* to /notify/verify carrying a signed token (address, language, time of consent, expiry). Opening
|
||||
* the link confirms: the handler checks the signature, sends the confirmed registration to
|
||||
* CONTACT_TO and redirects to /notify/confirmed. The office mailbox is the list.
|
||||
*
|
||||
* Configuration comes from the environment only, see README.md.
|
||||
*/
|
||||
@@ -268,7 +267,7 @@ async function notify(req, res, form, lang) {
|
||||
|
||||
const now = Date.now();
|
||||
const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS });
|
||||
const link = `${SITE}${lang === 'de' ? '/de' : ''}/notify/confirm?t=${token}`;
|
||||
const link = `${SITE}/notify/verify?t=${token}`;
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: { name: 'CipherCell', address: FROM },
|
||||
@@ -282,10 +281,10 @@ async function notify(req, res, form, lang) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Step two: the link's token comes back; the confirmed registration goes to the office. */
|
||||
async function verify(req, res, form) {
|
||||
const t = unsign(form.get('t'));
|
||||
const lang = t?.lang === 'de' ? 'de' : form.get('lang') === 'de' ? 'de' : 'en';
|
||||
/** Step two: the link is opened; the confirmed registration goes to the office. */
|
||||
async function verify(req, res, query) {
|
||||
const t = unsign(query.get('t'));
|
||||
const lang = t?.lang === 'de' ? 'de' : 'en';
|
||||
const now = Date.now();
|
||||
if (!t || typeof t.email !== 'string' || !(t.expires > now) || !(t.consented > 0)) {
|
||||
redirect(res, lang, '/notify/error');
|
||||
@@ -324,16 +323,20 @@ async function verify(req, res, form) {
|
||||
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
|
||||
const clientIp = (req) => String(req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown');
|
||||
|
||||
const ROUTES = { '/contact': contact, '/notify': notify, '/notify/verify': verify };
|
||||
const ROUTES = { '/contact': contact, '/notify': notify };
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = (req.url || '').split('?')[0];
|
||||
const [path, search = ''] = (req.url || '').split('?');
|
||||
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
|
||||
if (path === '/contact/challenge' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(await issueChallenge()));
|
||||
return;
|
||||
}
|
||||
if (path === '/notify/verify' && req.method === 'GET') {
|
||||
await verify(req, res, new URLSearchParams(search));
|
||||
return;
|
||||
}
|
||||
const route = ROUTES[path];
|
||||
if (req.method !== 'POST' || !route) { res.writeHead(404).end(); return; }
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 443 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
@@ -7,7 +7,6 @@ const { lang, tone = 'paper' } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const home = localePath(lang, '/');
|
||||
const items = [
|
||||
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
|
||||
{ href: localePath(lang, '/cipherverifier'), label: ui.nav.product },
|
||||
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
|
||||
{ href: localePath(lang, '/team'), label: ui.nav.team },
|
||||
|
||||
@@ -93,6 +93,8 @@ const { for: formId, labels } = Astro.props;
|
||||
color: var(--ink-muted-on-night);
|
||||
}
|
||||
.toast__message:empty { display: none; }
|
||||
/* With the head alone (while sending), it sits centred in the panel. */
|
||||
.toast:has(.toast__message:empty) { padding-bottom: var(--space-4); }
|
||||
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
|
||||
to read. Pointer or focus pauses it. */
|
||||
.toast__timer {
|
||||
|
||||
@@ -11,28 +11,18 @@ const ui = t(lang);
|
||||
|
||||
/* The partner logos as on ciphercell.de (see PartnerLogo.astro for the two states). `h` balances
|
||||
optical size: the incubator wordmark is long and thin, the other two carry a mark. */
|
||||
const roles = ui.proof.partnerRoles;
|
||||
const roles = ui.partners.roles;
|
||||
const logos = await Promise.all([
|
||||
{ name: 'xG-Incubator', role: roles.incubator, src: incubator, h: 'var(--space-5)' },
|
||||
{ name: 'xG-ALOE', role: roles.aloe, src: aloe, h: 'var(--space-7)' },
|
||||
{ name: lang === 'de' ? 'Universität Passau' : 'University of Passau', role: roles.passau, src: passau, h: 'var(--space-7)' },
|
||||
].map(async (l) => ({ ...l, url: (await getImage({ src: l.src, format: 'webp' })).src })));
|
||||
---
|
||||
<section class="section proof" aria-labelledby="proof-title">
|
||||
<section class="section section--tight partners" aria-labelledby="partners-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 class="partners">
|
||||
<p class="small muted" id="partners-label">{ui.proof.partners}</p>
|
||||
<ul class="partners__list" role="list" aria-labelledby="partners-label">
|
||||
<div class="partners__block">
|
||||
<h2 class="small muted partners__title" id="partners-title">{ui.partners.title}</h2>
|
||||
<ul class="partners__list" role="list">
|
||||
{logos.map((l) => (
|
||||
<li class="partners__item" data-partner>
|
||||
<PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} />
|
||||
@@ -48,18 +38,8 @@ const logos = await Promise.all([
|
||||
</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; }
|
||||
.partners__title { font-weight: var(--weight-regular); }
|
||||
|
||||
|
||||
/* Hover answers the pointer with the logo's own ground: night, original colours. */
|
||||
[data-partner] { transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
|
||||
@@ -68,9 +48,8 @@ const logos = await Promise.all([
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { [data-partner] { transition: none; } }
|
||||
|
||||
.partners {
|
||||
.partners__block {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: var(--space-9);
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
@@ -115,9 +94,6 @@ const logos = await Promise.all([
|
||||
}
|
||||
|
||||
@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); }
|
||||
.partners__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.partners__item + .partners__item { border-top: 0; border-left: var(--hairline) solid var(--line); }
|
||||
}
|
||||
@@ -223,7 +223,7 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
|
||||
<script>
|
||||
/**
|
||||
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
|
||||
* the pointer or focus is inside or the section is off screen; picking a statement stops it.
|
||||
* keyboard focus is inside or the section is off screen; picking a statement stops it.
|
||||
*/
|
||||
const root = document.querySelector<HTMLElement>('[data-quotes]');
|
||||
if (root) {
|
||||
@@ -259,10 +259,8 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
|
||||
picks.forEach((pick, i) => pick.addEventListener('click', () => { setPlaying(false); show(i); }));
|
||||
|
||||
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
|
||||
root.addEventListener('pointerenter', () => hold(true));
|
||||
root.addEventListener('pointerleave', () => hold(root.contains(document.activeElement)));
|
||||
root.addEventListener('focusin', () => hold(true));
|
||||
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(root.matches(':hover')); });
|
||||
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
|
||||
|
||||
// Off screen, the clock stops, so a reader arriving from above starts on a fresh statement.
|
||||
new IntersectionObserver(([entry]) => root.toggleAttribute('data-away', !entry.isIntersecting), { threshold: 0.4 }).observe(root);
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
import { Picture } from 'astro:assets';
|
||||
import fiveG from '../../assets/images/sector-5g.jpg';
|
||||
import substation from '../../assets/images/sector-substation.jpg';
|
||||
import controlRoom from '../../assets/images/sector-ot.jpg';
|
||||
import products from '../../assets/images/sector-rd-engineer.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}` }));
|
||||
const images = [fiveG, substation, controlRoom, products, datacentre];
|
||||
/* Where each picture keeps its subject when cropped; the R&D engineer sits on the left. */
|
||||
const focus = ['center', 'center', 'center', '30% center', 'center'];
|
||||
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], focus: focus[i], id: `sector-${i + 1}` }));
|
||||
---
|
||||
<section class="section sectors" id="sectors" aria-labelledby="sectors-title">
|
||||
<div class="container sectors__grid" data-sectors>
|
||||
@@ -36,11 +40,12 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
alt={item.imageAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 768, 1024, 1440]}
|
||||
sizes="(min-width: 64rem) 50vw, 100vw"
|
||||
widths={[480, 768, 1024, 1440, 1920]}
|
||||
sizes="(min-width: 64rem) 60vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="sectors__img img-doc"
|
||||
style={`object-position: ${item.focus}`}
|
||||
pictureAttributes={{ class: 'sectors__picture' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -56,11 +61,12 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
alt=""
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 768, 1024, 1440]}
|
||||
sizes="(min-width: 64rem) 50vw, 100vw"
|
||||
widths={[480, 768, 1024, 1440, 1920]}
|
||||
sizes="(min-width: 64rem) 60vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="sectors__img img-doc"
|
||||
style={`object-position: ${item.focus}`}
|
||||
pictureAttributes={{ class: 'sectors__picture sectors__picture--stage', 'data-stage-item': '', 'data-active': i === 0 ? '' : undefined }}
|
||||
/>
|
||||
))}
|
||||
@@ -69,6 +75,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.sectors { border-top: var(--hairline) solid var(--line); }
|
||||
.sectors__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
@@ -81,11 +88,34 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.sectors__tabs { margin-top: auto; }
|
||||
.sectors__tab + .sectors__tab { margin-top: var(--space-5); }
|
||||
/* The tabs are rows between hairlines. The selected row's rule turns ink: that is the marker. */
|
||||
.sectors__tabs {
|
||||
margin-top: auto;
|
||||
border-bottom: var(--hairline) solid var(--line);
|
||||
}
|
||||
.sectors__tab {
|
||||
position: relative;
|
||||
border-top: var(--hairline) solid var(--line);
|
||||
}
|
||||
.sectors__tab::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(-1 * var(--hairline));
|
||||
height: var(--hairline);
|
||||
background: var(--ink);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform var(--dur-slow) var(--ease);
|
||||
}
|
||||
.sectors__tab:has([aria-selected='true'])::before { transform: none; }
|
||||
.sectors__heading { font-size: inherit; font-weight: inherit; }
|
||||
/* The whole row is the button, so the target is as wide as the column. */
|
||||
.sectors__button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-block: var(--space-5) var(--space-3);
|
||||
text-align: left;
|
||||
font-size: var(--step-h3);
|
||||
line-height: var(--leading-h3);
|
||||
@@ -96,7 +126,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
}
|
||||
.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-inner { display: grid; gap: var(--space-5); }
|
||||
/* The button keeps a small step below its title, so an open body sits close under it and the
|
||||
focus ring never crosses the text. The rest of the row padding is the panel's margin: below
|
||||
the body when open, in place of it when closed, so every row is symmetric. */
|
||||
.sectors__panel { margin-bottom: var(--space-5); }
|
||||
.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; }
|
||||
@@ -110,19 +144,44 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template-rows var(--dur-slow) var(--ease);
|
||||
transition:
|
||||
grid-template-rows var(--dur-slow) var(--ease),
|
||||
margin 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; }
|
||||
/* A closed panel is hidden, not only collapsed, so it does not take focus while invisible.
|
||||
Visibility switches at the end of the collapse and at the start of the expand. */
|
||||
:global(html.js) .sectors__panel:not([data-open]) {
|
||||
margin-bottom: calc(var(--space-5) - var(--space-3));
|
||||
visibility: hidden;
|
||||
transition:
|
||||
grid-template-rows var(--dur-slow) var(--ease),
|
||||
margin var(--dur-slow) var(--ease),
|
||||
visibility 0s linear var(--dur-slow);
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.sectors__text { grid-column: 1 / span 5; }
|
||||
/* The image runs past the container to the right edge of the window: the page gutter, plus
|
||||
half of whatever the window is wider than the container. The section clips, so a classic
|
||||
scrollbar (counted in 100vw) cannot cause a horizontal scroll. */
|
||||
.sectors { overflow-x: clip; }
|
||||
/* The image box is at least 5:4 (never taller than 45rem), and stretches with the row when
|
||||
the list beside it is taller, so its top and bottom always meet the heading and the last rule. */
|
||||
.sectors__media {
|
||||
grid-column: 7 / span 6;
|
||||
display: grid;
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-right: calc(-1 * (max(var(--gutter), var(--safe-right)) + max(0px, (100vw - var(--container)) / 2)));
|
||||
}
|
||||
/* The minimum: a spacer as tall as 5:4 of the width, capped at 45rem. A grid item stretches
|
||||
to its row, so a taller list beside it makes the box taller; the pictures fill the box. */
|
||||
.sectors__media::before { content: ''; display: block; padding-top: min(80%, 45rem); }
|
||||
.sectors__media :global(.sectors__img) { height: 100%; aspect-ratio: auto; }
|
||||
.sectors__media :global(.sectors__picture--stage) {
|
||||
grid-area: 1 / 1;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-slow) var(--ease);
|
||||
}
|
||||
@@ -132,7 +191,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sectors__panel, .sectors__media :global(.sectors__picture--stage) { transition: none; }
|
||||
.sectors__panel,
|
||||
:global(html.js) .sectors__panel,
|
||||
:global(html.js) .sectors__panel:not([data-open]),
|
||||
.sectors__tab::before,
|
||||
.sectors__media :global(.sectors__picture--stage) { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ 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 type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM' | 'networkengineer';
|
||||
|
||||
export interface Person {
|
||||
name: string;
|
||||
@@ -20,7 +20,7 @@ export const team: Person[] = [
|
||||
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix },
|
||||
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian },
|
||||
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander },
|
||||
{ name: 'Erik Foris', role: 'engineer', portrait: erik },
|
||||
{ name: 'Erik Foris', role: 'networkengineer', portrait: erik },
|
||||
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc },
|
||||
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp },
|
||||
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin },
|
||||
|
||||
+28
-46
@@ -39,7 +39,6 @@ const en = {
|
||||
},
|
||||
nav: {
|
||||
skip: 'Skip to content',
|
||||
capabilities: 'Capabilities',
|
||||
product: 'Product',
|
||||
sectors: 'Sectors',
|
||||
insights: 'Insights',
|
||||
@@ -54,8 +53,8 @@ const en = {
|
||||
},
|
||||
manifesto: {
|
||||
lines: ['Security built in,', 'not bolted on.'],
|
||||
p1: 'Infrastructure is software now. Private 5G networks, industrial control systems and cloud platforms are assembled from the code and 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 for software. We model real adversaries, build zero trust into the architecture rather than around it, and prove that the code holds. From the first threat model to the running system.',
|
||||
p1: 'Infrastructure is software now. Private 5G networks, industrial control systems, digital products and cloud platforms are assembled from the code and components of many vendors, and every interface between them is an attack surface that off-the-shelf tools were never designed to cover.',
|
||||
p2: 'CipherCell is a security engineering company built by specialists in networks and software. We take on the hard problems, whatever the vertical, and solve them with security at the core of every decision.',
|
||||
cta: 'About CipherCell',
|
||||
},
|
||||
method: {
|
||||
@@ -82,22 +81,18 @@ const en = {
|
||||
],
|
||||
},
|
||||
sectors: {
|
||||
title: 'Who we work with',
|
||||
title: 'Who we work with.',
|
||||
items: [
|
||||
{ title: 'Private 5G and campus networks', body: 'Software-defined 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 infrastructure', body: 'Control and network software 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: 'Operational technology (OT)', body: 'Controllers, SCADA and the engineering software that configures them. Systems built to run for decades, often on protocols without authentication, and rarely patched.', imageAlt: 'Three operators in hard hats at their consoles in a plant control room, facing a wall-sized diagram of the process.' },
|
||||
{ title: 'Products with digital elements', body: 'Devices, firmware and installable software sold in the EU. Under the Cyber Resilience Act, the manufacturer answers for their security for the whole support period.', imageAlt: 'An engineer at two monitors in a glass-walled development lab, engineering software on screen and a machined test part on the desk.' },
|
||||
{ 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.' },
|
||||
],
|
||||
partners: 'Trusted by and working with',
|
||||
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
|
||||
partners: {
|
||||
title: 'Trusted by and working with',
|
||||
roles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
|
||||
},
|
||||
quote: {
|
||||
label: 'Statements from the founders',
|
||||
@@ -253,13 +248,13 @@ const en = {
|
||||
soon: {
|
||||
eyebrow: 'Product',
|
||||
title: ['CipherVerifier.', 'Coming soon.'],
|
||||
lead: 'A self-hosted verification pipeline for software. Currently in a closed demonstrator phase.',
|
||||
lead: 'A self-hosted security pipeline for software. Currently in a closed demonstrator phase.',
|
||||
status: {
|
||||
label: 'System status',
|
||||
rows: [
|
||||
{ key: 'Phase', value: 'Closed demonstrator', live: true },
|
||||
{ key: 'Next', value: 'Alpha release' },
|
||||
{ key: 'Deployment', value: 'Self-hosted' },
|
||||
{ key: 'Focus', value: 'Software security' },
|
||||
],
|
||||
},
|
||||
notify: {
|
||||
@@ -278,11 +273,6 @@ const en = {
|
||||
failed: 'Registration did not go through. Please try again, or write to office@ciphercell.de.',
|
||||
},
|
||||
pages: {
|
||||
confirm: {
|
||||
title: 'Confirm your registration.',
|
||||
body: 'One click, and your address is registered for the CipherVerifier alpha. One email when it is released. Nothing else.',
|
||||
submit: 'Confirm registration',
|
||||
},
|
||||
confirmed: {
|
||||
title: 'Registered.',
|
||||
body: 'Your address is confirmed. You will hear from us once, when the CipherVerifier alpha is released.',
|
||||
@@ -337,10 +327,11 @@ const en = {
|
||||
roles: {
|
||||
ceo: 'Founder and CEO',
|
||||
cto: 'Founder and CTO',
|
||||
engineer: 'Software engineer',
|
||||
bizdev: 'Business development',
|
||||
studentF: 'Working student, development',
|
||||
studentM: 'Working student, development',
|
||||
engineer: 'Software Engineer',
|
||||
networkengineer: 'Network & Infrastructure Engineer',
|
||||
bizdev: 'Business Development',
|
||||
studentF: 'Mathematical Engineer',
|
||||
studentM: 'Process Optimization & Automation',
|
||||
},
|
||||
research: {
|
||||
title: ['Published', 'research.'],
|
||||
@@ -389,7 +380,6 @@ const de: typeof en = {
|
||||
},
|
||||
nav: {
|
||||
skip: 'Zum Inhalt springen',
|
||||
capabilities: 'Leistungen',
|
||||
product: 'Produkt',
|
||||
sectors: 'Einsatzfelder',
|
||||
insights: 'Einblicke',
|
||||
@@ -404,8 +394,8 @@ const de: typeof en = {
|
||||
},
|
||||
manifesto: {
|
||||
lines: ['Sicherheit eingebaut,', 'nicht nachträglich angefügt.'],
|
||||
p1: 'Infrastruktur ist heute Software. Private 5G-Netze, industrielle Steuerungssysteme und Cloud-Plattformen werden aus Code und 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 für Software. Wir modellieren reale Angreifer, verankern Zero Trust in der Architektur statt daneben und weisen nach, dass der Code standhält. Vom ersten Bedrohungsmodell bis zum laufenden System.',
|
||||
p1: 'Infrastruktur ist heute Software. Private 5G-Netze, industrielle Steuerungssysteme, digitale Produkte und Cloud-Plattformen setzen sich aus Code und Komponenten vieler Hersteller zusammen und jede Schnittstelle dazwischen ist eine Angriffsfläche, für die Lösungen von der Stange nie gedacht waren.',
|
||||
p2: 'CipherCell ist ein Security-Engineering-Unternehmen von Spezialisten für Netzwerke und Software. Wir packen die schwierigen Probleme an, ganz gleich in welcher Branche, und machen Sicherheit zum Kern jeder Entscheidung.',
|
||||
cta: 'Über CipherCell',
|
||||
},
|
||||
method: {
|
||||
@@ -432,22 +422,18 @@ const de: typeof en = {
|
||||
],
|
||||
},
|
||||
sectors: {
|
||||
title: 'Für wen wir arbeiten',
|
||||
title: 'Für wen wir arbeiten.',
|
||||
items: [
|
||||
{ title: 'Private 5G- und Campusnetze', body: 'Softwaredefinierte 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 Infrastruktur', body: 'Steuerungs- und Netzsoftware, bei der ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' },
|
||||
{ title: 'Operational Technology (OT)', body: 'Steuerungen, SCADA und die Engineering-Software, mit der sie konfiguriert werden. Anlagen, die Jahrzehnte laufen, oft über Protokolle ohne Authentisierung, und selten Updates erhalten.', imageAlt: 'Drei Bediener mit Schutzhelm an ihren Pulten in der Leitwarte eines Werks, vor einem wandgroßen Schema der Anlage.' },
|
||||
{ title: 'Produkte mit digitalen Elementen', body: 'Geräte, Firmware und installierbare Software, die in der EU verkauft werden. Nach dem Cyber Resilience Act steht der Hersteller über den gesamten Supportzeitraum für ihre Sicherheit ein.', imageAlt: 'Ein Ingenieur an zwei Monitoren in einem verglasten Entwicklungslabor, Engineering-Software auf dem Bildschirm und ein gefrästes Testteil auf dem Tisch.' },
|
||||
{ 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.' },
|
||||
],
|
||||
partners: 'Vertrauen und Zusammenarbeit',
|
||||
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
|
||||
partners: {
|
||||
title: 'Vertrauen und Zusammenarbeit',
|
||||
roles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
|
||||
},
|
||||
quote: {
|
||||
label: 'Aussagen der Gründer',
|
||||
@@ -603,13 +589,13 @@ const de: typeof en = {
|
||||
soon: {
|
||||
eyebrow: 'Produkt',
|
||||
title: ['CipherVerifier.', 'Demnächst.'],
|
||||
lead: 'Eine selbst gehostete Verifikations-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
|
||||
lead: 'Eine selbst gehostete Security-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
|
||||
status: {
|
||||
label: 'Systemstatus',
|
||||
rows: [
|
||||
{ key: 'Phase', value: 'Geschlossener Demonstrator', live: true },
|
||||
{ key: 'Als Nächstes', value: 'Alpha-Release' },
|
||||
{ key: 'Betrieb', value: 'Selbst gehostet' },
|
||||
{ key: 'Fokus', value: 'Software sicherheit' },
|
||||
],
|
||||
},
|
||||
notify: {
|
||||
@@ -628,11 +614,6 @@ const de: typeof en = {
|
||||
failed: 'Die Registrierung ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
|
||||
},
|
||||
pages: {
|
||||
confirm: {
|
||||
title: 'Registrierung bestätigen.',
|
||||
body: 'Ein Klick, und Ihre Adresse ist für die Alpha von CipherVerifier registriert. Eine E-Mail, sobald sie erscheint. Sonst nichts.',
|
||||
submit: 'Registrierung bestätigen',
|
||||
},
|
||||
confirmed: {
|
||||
title: 'Registriert.',
|
||||
body: 'Ihre Adresse ist bestätigt. Sie hören einmal von uns, sobald die Alpha von CipherVerifier erscheint.',
|
||||
@@ -688,9 +669,10 @@ const de: typeof en = {
|
||||
ceo: 'Gründer und CEO',
|
||||
cto: 'Gründer und CTO',
|
||||
engineer: 'Software Engineer',
|
||||
networkengineer: 'Network & Infrastructure Engineer',
|
||||
bizdev: 'Business Development',
|
||||
studentF: 'Werkstudentin, Entwicklung',
|
||||
studentM: 'Werkstudent, Entwicklung',
|
||||
studentF: 'Mathematical Engineer',
|
||||
studentM: 'Process Optimization & Automation',
|
||||
},
|
||||
research: {
|
||||
title: ['Veröffentlichte', 'Forschung.'],
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 Partners from '../components/home/Partners.astro';
|
||||
import Quote from '../components/home/Quote.astro';
|
||||
import Insights from '../components/home/Insights.astro';
|
||||
import Contact from '../components/home/Contact.astro';
|
||||
@@ -21,7 +21,7 @@ const ui = t(lang);
|
||||
<Method lang={lang} />
|
||||
<Stages lang={lang} />
|
||||
<Sectors lang={lang} />
|
||||
<Proof lang={lang} />
|
||||
<Partners lang={lang} />
|
||||
<Quote lang={lang} />
|
||||
<Insights lang={lang} />
|
||||
<Contact lang={lang} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { localePath, t, type Lang } from '../i18n/ui';
|
||||
interface Props { lang: Lang; page: 'confirm' | 'confirmed' | 'error' }
|
||||
interface Props { lang: Lang; page: 'confirmed' | 'error' }
|
||||
const { lang, page } = Astro.props;
|
||||
const p = t(lang).verifier.soon.notify.pages;
|
||||
const r = p[page];
|
||||
@@ -13,28 +13,12 @@ const product = localePath(lang, '/cipherverifier');
|
||||
<h1 class="display result__title">{r.title}</h1>
|
||||
<div class="result__body">
|
||||
<p class="muted">{r.body}</p>
|
||||
{page === 'confirm' ? (
|
||||
// The token travels in the link's query; the script copies it into the post. The
|
||||
// button, not the link, confirms: mail scanners open links but do not press buttons.
|
||||
<form action="/notify/verify" method="post" data-confirm>
|
||||
<input type="hidden" name="lang" value={lang} />
|
||||
<input type="hidden" name="t" value="" />
|
||||
<button class="button" type="submit">{p.confirm.submit}</button>
|
||||
</form>
|
||||
) : (
|
||||
<a class="link" href={page === 'error' ? `${product}#notify` : product}>{'link' in r ? r.link : ''}</a>
|
||||
)}
|
||||
<a class="link" href={page === 'error' ? `${product}#notify` : product}>{r.link}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
const form = document.querySelector<HTMLFormElement>('[data-confirm]');
|
||||
const field = form?.querySelector<HTMLInputElement>('[name="t"]');
|
||||
if (field) field.value = new URLSearchParams(location.search).get('t') ?? '';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.result__title { grid-column: 1 / -1; }
|
||||
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); justify-items: start; }
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
import NotifyPage from '../../_NotifyPage.astro';
|
||||
---
|
||||
<NotifyPage lang="de" page="confirm" />
|
||||
@@ -1,4 +0,0 @@
|
||||
---
|
||||
import NotifyPage from '../_NotifyPage.astro';
|
||||
---
|
||||
<NotifyPage lang="en" page="confirm" />
|
||||
Reference in new issue
Block a user