Compare commits

..
5 Commits
Author SHA1 Message Date
Felix Klement 7508f6e084 asdfdas 2026-10-04 22:43:25 +02:00
Felix Klement f84687d745 asdf 2026-10-04 22:29:17 +02:00
Felix Klement cc41849cf5 asdf 2026-10-04 21:10:01 +02:00
Felix Klement b195c9e666 asdf 2026-10-04 21:07:37 +02:00
Felix Klement 3e6cfcbe35 asdf 2026-10-04 20:58:21 +02:00
29 changed files with 912 additions and 72 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Contact form (server/contact.mjs). Copy to .env for `npm run contact`; set the same in Dokploy.
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=website@ciphercell.de
SMTP_PASS=
CONTACT_FROM=website@ciphercell.de
CONTACT_TO=office@ciphercell.de
+4
View File
@@ -10,3 +10,7 @@ ds-bundle/
.design-sync/learnings/ .design-sync/learnings/
.design-sync/node_modules .design-sync/node_modules
.DS_Store .DS_Store
# Local secrets for the contact handler
.env
.env.*
!.env.example
+12 -1
View File
@@ -10,10 +10,21 @@ RUN npm ci --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
# Serve dist/ with nginx. # The contact handler's production dependencies (nodemailer only).
FROM node:22-alpine AS contact
WORKDIR /srv/contact
COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY server/contact.mjs ./
# Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000.
FROM nginx:1.27-alpine AS runtime FROM nginx:1.27-alpine AS runtime
RUN apk add --no-cache nodejs
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/entrypoint.sh /entrypoint.sh
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=contact /srv/contact /srv/contact
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1 CMD wget -qO- http://127.0.0.1/healthz || exit 1
+30 -4
View File
@@ -26,7 +26,8 @@ docker run --rm -p 8080:80 ciphercell # http://localhost:8080
In Dokploy: create an Application from this repository, set Build Type to **Dockerfile** In Dokploy: create an Application from this repository, set Build Type to **Dockerfile**
(path `Dockerfile`), and under Domains add the domain with container port **80** and HTTPS on. (path `Dockerfile`), and under Domains add the domain with container port **80** and HTTPS on.
TLS is handled by Dokploy's Traefik. TLS is handled by Dokploy's Traefik. The contact form needs the SMTP variables under
Environment; see *Contact form* below.
## Where things live ## Where things live
@@ -43,6 +44,7 @@ TLS is handled by Dokploy's Traefik.
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. | | `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
| `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. | | `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. |
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. | | `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
| `server/contact.mjs` | The contact form handler: validates, rate-limits and sends by SMTP. Its own `package.json`, so the image only carries nodemailer. |
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. | | `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. | | `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. | | `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
@@ -56,9 +58,33 @@ TLS is handled by Dokploy's Traefik.
## Things the host has to provide ## Things the host has to provide
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` and a honeypot - **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
field `website` to `POST /contact`, as the current site does. The handler is server-side and honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs`
not part of this static build. (Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
Reply-To. With scripting the form sends in place and shows the result under the button;
without, the handler redirects to `/contact/sent` or `/contact/error` (under `/de` for German).
Both pages are `noindex` and left out of the sitemap. The handler caps size and field lengths,
refuses cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour
overall.
In the container, nginx proxies `/contact` 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.
| Variable | Default | Purpose |
|---|---|---|
| `SMTP_HOST` | (required) | Mail server. |
| `SMTP_PORT` | `587` | `587` uses STARTTLS (required), `465` TLS from the start. |
| `SMTP_USER`, `SMTP_PASS` | | Login. Leave unset for a server without authentication. |
| `CONTACT_FROM` | `SMTP_USER` | Sender address; must be one the SMTP account may send as. |
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. |
In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact`
to it.
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier` - **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`, shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is `list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is
+3 -1
View File
@@ -5,11 +5,13 @@ import sitemap from '@astrojs/sitemap';
export default defineConfig({ export default defineConfig({
site: 'https://www.ciphercell.de', site: 'https://www.ciphercell.de',
trailingSlash: 'never', trailingSlash: 'never',
integrations: [sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })], integrations: [sitemap({ filter: (page) => !/\/contact\//.test(page), i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })],
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
locales: ['en', 'de'], locales: ['en', 'de'],
routing: { prefixDefaultLocale: false }, routing: { prefixDefaultLocale: false },
}, },
build: { inlineStylesheets: 'auto' }, build: { inlineStylesheets: 'auto' },
// In development the form posts to the contact handler started with `npm run contact`.
vite: { server: { proxy: { '^/contact$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
}); });
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Runs the contact handler next to nginx. The handler is restarted if it crashes, but not when it
# exits for missing configuration (code 78); the static site is served either way.
(
cd /srv/contact || exit
while :; do
node contact.mjs
code=$?
[ "$code" -eq 78 ] && { echo "contact: not started, SMTP is not configured." >&2; exit; }
echo "contact: exited with $code, restarting in 5 s." >&2
sleep 5
done
) &
exec nginx -g 'daemon off;'
+25
View File
@@ -7,6 +7,13 @@ server {
# TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https. # TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https.
absolute_redirect off; absolute_redirect off;
# The client address arrives in X-Forwarded-For from Traefik on the private network.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
server_tokens off; server_tokens off;
gzip on; gzip on;
@@ -24,6 +31,24 @@ server {
return 200 "ok\n"; return 200 "ok\n";
} }
# The contact form is the one dynamic path: server/contact.mjs, sending by SMTP.
location = /contact {
limit_except POST { deny all; }
client_max_body_size 64k;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
proxy_intercept_errors on;
error_page 502 503 504 = @contact_down;
}
# Handler not running: send the visitor to the error page in their language.
location @contact_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; }
return 303 /contact/error;
}
# trailingSlash: 'never' — /team is served from /team/index.html; /team/ redirects to /team. # trailingSlash: 'never' — /team is served from /team/index.html; /team/ redirects to /team.
location ~ ^(.+)/$ { location ~ ^(.+)/$ {
return 301 $1$is_args$args; return 301 $1$is_args$args;
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev", "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
"build": "ASTRO_TELEMETRY_DISABLED=1 astro build", "build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check" "check": "ASTRO_TELEMETRY_DISABLED=1 astro check",
"contact": "node --env-file-if-exists=.env server/contact.mjs"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0", "@fontsource-variable/inter-tight": "^5.3.0",
+153
View File
@@ -0,0 +1,153 @@
/**
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static.
*
* The form posts first_name, last_name, email, message, lang and the honeypot website. A browser
* with scripting asks for JSON and shows the result in place; without scripting the answer is a
* redirect to /contact/sent or /contact/error (under /de for German).
*
* Configuration comes from the environment only, see README.md.
*/
import { createServer } from 'node:http';
import nodemailer from 'nodemailer';
const env = process.env;
const PORT = Number(env.CONTACT_PORT || 3000);
const HOST = env.CONTACT_HOST || '127.0.0.1';
const TO = env.CONTACT_TO || 'office@ciphercell.de';
const FROM = env.CONTACT_FROM || env.SMTP_USER;
const SMTP_PORT = Number(env.SMTP_PORT || 587);
if (!env.SMTP_HOST || !FROM) {
console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.');
process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one.
}
const transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: SMTP_PORT,
// 465 speaks TLS from the first byte; 587 and 25 upgrade with STARTTLS, which we then require.
// SMTP_REQUIRE_TLS=false is for a local mail catcher only.
secure: env.SMTP_SECURE ? env.SMTP_SECURE === 'true' : SMTP_PORT === 465,
requireTLS: env.SMTP_REQUIRE_TLS !== 'false' && SMTP_PORT !== 465,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
});
/* Limits. The body cap is generous for four short fields; the rate caps stop a flood, not a person. */
const MAX_BODY = 32 * 1024;
const MAX = { first_name: 100, last_name: 100, email: 254, message: 5000 };
const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 };
const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 };
const hits = new Map();
const globalHits = [];
function limited(ip, now = Date.now()) {
while (globalHits.length && now - globalHits[0] > GLOBAL.windowMs) globalHits.shift();
const mine = (hits.get(ip) || []).filter((t) => now - t < PER_IP.windowMs);
if (mine.length >= PER_IP.count || globalHits.length >= GLOBAL.count) return true;
mine.push(now);
hits.set(ip, mine);
globalHits.push(now);
return false;
}
setInterval(() => {
const now = Date.now();
for (const [ip, list] of hits) if (list.every((t) => now - t >= PER_IP.windowMs)) hits.delete(ip);
}, PER_IP.windowMs).unref();
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const oneLine = (s) => s.replace(/[\r\n\t]+/g, ' ').trim();
function validate(form) {
const data = {
first_name: oneLine(form.get('first_name') || ''),
last_name: oneLine(form.get('last_name') || ''),
email: oneLine(form.get('email') || ''),
message: (form.get('message') || '').replace(/\r\n/g, '\n').trim(),
};
for (const [key, max] of Object.entries(MAX)) {
if (!data[key] || data[key].length > max) return null;
}
return EMAIL.test(data.email) ? data : null;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY) { reject(new Error('too large')); req.destroy(); return; }
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
function reply(req, res, lang, outcome) {
const status = { sent: 200, invalid: 400, limited: 429, failed: 502 }[outcome];
if ((req.headers.accept || '').includes('application/json')) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
return;
}
const page = outcome === 'sent' ? 'sent' : 'error';
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' });
res.end();
}
const server = createServer(async (req, res) => {
const path = (req.url || '').split('?')[0];
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
// A cross-site post is not a visitor using the form.
const origin = req.headers.origin;
const host = req.headers['x-forwarded-host'] || req.headers.host;
if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; }
let form;
try {
form = new URLSearchParams(await readBody(req));
} catch {
res.writeHead(413).end();
return;
}
const lang = form.get('lang') === 'de' ? 'de' : 'en';
// Filled honeypot: answer as if sent, so the bot learns nothing.
if (form.get('website')) { reply(req, res, lang, 'sent'); return; }
const data = validate(form);
if (!data) { reply(req, res, lang, 'invalid'); return; }
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown';
if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; }
const name = `${data.first_name} ${data.last_name}`;
try {
await transport.sendMail({
from: { name: 'CipherCell website', address: FROM },
to: TO,
replyTo: { name, address: data.email },
subject: `Website enquiry from ${name}`,
text: [
`Name: ${name}`,
`Email: ${data.email}`,
`Language: ${lang}`,
'',
data.message,
'',
'-- ',
'Sent from the contact form on ciphercell.de. Reply to this email to answer.',
].join('\n'),
});
reply(req, res, lang, 'sent');
} catch (err) {
console.error('contact: sending failed:', err.message);
reply(req, res, lang, 'failed');
}
});
server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ciphercell-contact",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ciphercell-contact",
"dependencies": {
"nodemailer": "^7.0.13"
},
"engines": {
"node": ">=20"
}
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ciphercell-contact",
"private": true,
"type": "module",
"description": "Receives the contact form and sends it on by SMTP. Runs next to nginx; see README.",
"scripts": {
"start": "node contact.mjs"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"nodemailer": "^7.0.13"
}
}
+14 -3
View File
@@ -2,8 +2,8 @@
import Wordmark from './Wordmark.astro'; import Wordmark from './Wordmark.astro';
import { localePath, t, type Lang } from '../i18n/ui'; import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang; tone?: 'paper' | 'night' }
const { lang } = Astro.props; const { lang, tone = 'paper' } = Astro.props;
const ui = t(lang); const ui = t(lang);
const home = localePath(lang, '/'); const home = localePath(lang, '/');
const items = [ const items = [
@@ -16,7 +16,7 @@ const contactHref = `${home}#contact`;
const path = Astro.url.pathname.replace(/\/$/, '') || '/'; const path = Astro.url.pathname.replace(/\/$/, '') || '/';
const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#'); const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#');
--- ---
<header class="header" data-header data-tone="paper"> <header class="header" data-header data-tone={tone} data-tone-init>
<div class="container header__shell"> <div class="container header__shell">
<div class="header__bar hud-frame"> <div class="header__bar hud-frame">
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}> <a class="header__home" href={home} aria-label={ui.nav.homeLabel}>
@@ -102,6 +102,12 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
background: var(--glass-solid); background: var(--glass-solid);
transition: background-color var(--dur) var(--ease); transition: background-color var(--dur) var(--ease);
} }
/* Until the script has settled the tone on load, it changes without a fade: the tone is a fact
about where the page is, not a change the visitor made. */
.header[data-tone-init],
.header[data-tone-init] *,
.header[data-tone-init] ::before,
.header[data-tone-init] ::after { transition: none !important; }
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.header__bar::before, .header__bar::before,
.header__nav[data-open]::before { .header__nav[data-open]::before {
@@ -242,6 +248,8 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
/* Tone: watch a one-pixel line through the middle of the bar and switch to night while a /* Tone: watch a one-pixel line through the middle of the bar and switch to night while a
.night section crosses it. An IntersectionObserver, so nothing runs on scroll. */ .night section crosses it. An IntersectionObserver, so nothing runs on scroll. */
const sections = document.querySelectorAll<HTMLElement>('.night'); const sections = document.querySelectorAll<HTMLElement>('.night');
// Transitions come back once the first tone is drawn (two frames, so it has been painted).
const settle = () => requestAnimationFrame(() => requestAnimationFrame(() => header?.removeAttribute('data-tone-init')));
if (header && sections.length && 'IntersectionObserver' in window) { if (header && sections.length && 'IntersectionObserver' in window) {
const under = new Set<Element>(); const under = new Set<Element>();
let observer: IntersectionObserver | undefined; let observer: IntersectionObserver | undefined;
@@ -254,6 +262,7 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target); if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target);
} }
header.dataset.tone = under.size ? 'night' : 'paper'; header.dataset.tone = under.size ? 'night' : 'paper';
if (header.hasAttribute('data-tone-init')) settle();
}, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` }); }, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` });
sections.forEach((section) => observer!.observe(section)); sections.forEach((section) => observer!.observe(section));
}; };
@@ -263,5 +272,7 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
window.clearTimeout(resize); window.clearTimeout(resize);
resize = window.setTimeout(watch, 150); resize = window.setTimeout(watch, 150);
}); });
} else {
settle();
} }
</script> </script>
+187 -1
View File
@@ -16,7 +16,17 @@ const f = ui.contact.form;
</address> </address>
</div> </div>
<form class="contact__form" action="/contact" method="post" lang={lang}> <form
class="contact__form"
action="/contact"
method="post"
lang={lang}
data-contact
data-sent={f.sent}
data-invalid={f.invalid}
data-failed={f.failed}
>
<input type="hidden" name="lang" value={lang} />
<p class="visually-hidden" aria-hidden="true"> <p class="visually-hidden" aria-hidden="true">
<label for="website">{f.honeypot}</label> <label for="website">{f.honeypot}</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" /> <input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
@@ -43,6 +53,22 @@ const f = ui.contact.form;
</div> </div>
</form> </form>
</div> </div>
<!-- The form's answer, as a toast in the lower right. Framed like the menu, on night. -->
<div
class="toast hud-frame"
data-toast
data-label-sending={f.toast.sending}
data-label-sent={f.toast.sent}
data-label-failed={f.toast.failed}
>
<div class="toast__head">
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
<button class="toast__close" type="button" data-toast-close>{f.toast.close}</button>
</div>
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
</div>
</section> </section>
<style> <style>
@@ -89,6 +115,107 @@ const f = ui.contact.form;
margin-top: var(--space-2); margin-top: var(--space-2);
} }
.contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; }
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
reach out onto the page, so they take the menu's line colour, not the night one. */
.toast {
--frame-color: var(--hud-line);
position: fixed;
right: var(--gutter);
bottom: var(--gutter);
z-index: 20;
width: min(26rem, calc(100% - 2 * var(--gutter)));
display: grid;
gap: var(--space-3);
padding: var(--space-4) var(--space-5) var(--space-5);
background: var(--night);
color: var(--ink-on-night);
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
just sinks back, quicker and without the overshoot. */
opacity: 0;
visibility: hidden;
transform: translateY(var(--space-5)) scale(0.94);
transform-origin: bottom right;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease),
visibility 0s linear var(--dur-fast);
}
.toast[data-open] {
opacity: 1;
visibility: visible;
transform: none;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-slow) var(--ease-pop);
}
.toast__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toast__label,
.toast__close {
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
}
.toast__label { color: var(--ink-on-night); }
/* While sending, three dots after the label light up one after another. */
.toast__dots { display: none; }
.toast[data-state='sending'] .toast__dots { display: inline; }
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
.toast__close {
color: var(--ink-muted-on-night);
padding: var(--space-2);
margin: calc(-1 * var(--space-2));
transition: color var(--dur-fast) var(--ease);
}
.toast__close:hover { color: var(--ink-on-night); }
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
.toast__message {
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* 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 {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--hud-line-width);
background: var(--ink-muted-on-night);
transform-origin: left;
transform: scaleX(0);
}
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
.toast:hover .toast__timer,
.toast:focus-within .toast__timer { animation-play-state: paused; }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* One shared cycle in four beats: none, one, two, three dots. */
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-open] { transition: none; transform: none; }
.toast__dots span { animation: none; }
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
.toast__timer { visibility: hidden; }
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.contact__text { grid-column: 1 / span 5; } .contact__text { grid-column: 1 / span 5; }
.contact__form { grid-column: 7 / span 6; } .contact__form { grid-column: 7 / span 6; }
@@ -98,3 +225,62 @@ const f = ui.contact.form;
.field { grid-column: 1 / -1; } .field { grid-column: 1 / -1; }
} }
</style> </style>
<script>
// With scripting the form sends in place and answers in a toast. Without it, the server
// redirects to /contact/sent or /contact/error.
const form = document.querySelector<HTMLFormElement>('[data-contact]');
const toast = document.querySelector<HTMLElement>('[data-toast]');
if (form && toast) {
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new URLSearchParams(new FormData(form) as unknown as Record<string, string>),
});
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}
</script>
+8 -3
View File
@@ -69,16 +69,21 @@ const m = ui.insights.mention;
height: 100%; height: 100%;
} }
.insights__card:hover h3 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; } .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__img, :global(.insights__img) { width: 100%; height: auto; aspect-ratio: 4 / 1; object-fit: cover; object-position: 50% 15%; margin-bottom: var(--space-2); }
.insights__card h3 { max-width: 18em; } .insights__card h3 { max-width: 18em; }
/* No picture: a colour field carries the source in large type. */ /* No picture: a colour field carries the source in large type, centred in the short strip,
with the field's darkening moved behind it. */
:global(.insights__panel) { :global(.insights__panel) {
aspect-ratio: 3 / 2; --field-scrim: var(--field-scrim-centre);
aspect-ratio: 4 / 1;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
font-size: var(--step-title); font-size: var(--step-title);
line-height: var(--leading-title); line-height: var(--leading-title);
letter-spacing: var(--track-h2); letter-spacing: var(--track-h2);
} }
/* Field's own scoped rule (a class and an attribute) puts its text at the bottom; three
classes outrank it whatever order the stylesheets load in. */
:global(.insights__card .field.insights__panel) { justify-content: center; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); } .insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
+28 -5
View File
@@ -74,15 +74,39 @@ const logos = await Promise.all([
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
} }
.partners__list { display: grid; border-top: var(--hairline) solid var(--line); } .partners__list {
position: relative;
display: grid;
border-block: var(--hairline) solid var(--line);
}
/* The menu bar's corner cross on two opposite corners of the row, upper left and lower right,
where its rules end, in the rules' colour. Drawn as in .hud-frame (base.css), but not with that class: it redefines
--line as a width, and the rules here need --line as the colour. */
.partners__list::after {
content: '';
position: absolute;
--arm: var(--hud-arm);
--w: var(--hud-line-width);
--len: calc(2 * var(--arm) + var(--w));
--g: linear-gradient(var(--line), var(--line)) no-repeat; /* the rules' own colour */
inset: calc(-1 * (var(--arm) + var(--w)));
/* The inner horizontal arm is the rule itself; drawing it again would double the
translucent colour, so only the outer arm and the vertical are painted. */
background:
var(--g) left 0 top var(--arm) / var(--arm) var(--w),
var(--g) left var(--arm) top 0 / var(--w) var(--len),
var(--g) right 0 bottom var(--arm) / var(--arm) var(--w),
var(--g) right var(--arm) bottom 0 / var(--w) var(--len);
pointer-events: none;
}
.partners__item { .partners__item {
display: grid; display: grid;
align-content: space-between; align-content: space-between;
gap: var(--space-7); gap: var(--space-7);
min-height: calc(var(--space-9) * 2.5); min-height: calc(var(--space-9) * 2.5);
padding: var(--space-6) var(--space-5); padding: var(--space-6) var(--space-5);
border-bottom: var(--hairline) solid var(--line);
} }
.partners__item + .partners__item { border-top: var(--hairline) solid var(--line); }
.partners__text { display: grid; gap: var(--space-1); } .partners__text { display: grid; gap: var(--space-1); }
.partners__name { font-weight: var(--weight-medium); } .partners__name { font-weight: var(--weight-medium); }
.partners__role { color: var(--ink-muted); transition: color var(--dur-fast) var(--ease); } .partners__role { color: var(--ink-muted); transition: color var(--dur-fast) var(--ease); }
@@ -94,8 +118,7 @@ const logos = await Promise.all([
.proof__title { grid-column: 1 / span 6; } .proof__title { grid-column: 1 / span 6; }
.proof__list { grid-column: 7 / 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); } .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)); border-bottom: var(--hairline) solid var(--line); } .partners__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.partners__item { border-bottom: 0; } .partners__item + .partners__item { border-top: 0; border-left: var(--hairline) solid var(--line); }
.partners__item + .partners__item { border-left: var(--hairline) solid var(--line); }
} }
</style> </style>
+240 -29
View File
@@ -1,44 +1,87 @@
--- ---
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import felix from '../../assets/images/people/felix.png'; import felix from '../../assets/images/people/felix.png';
import florian from '../../assets/images/people/florian.png';
import { localePath, t, type Lang } from '../../i18n/ui'; import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
const essay = localePath(lang, '/insights/xg-aloe-user-forum'); // Where each statement's eyebrow leads, and whose portrait sits beside it. Same order as ui.quote.items.
const sources = [
{ href: localePath(lang, '/insights/xg-aloe-user-forum'), portrait: felix },
{ href: `${localePath(lang, '/team')}#research-title`, portrait: florian },
];
const items = ui.quote.items.map((item, i) => ({ ...item, ...sources[i] }));
const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}”`);
--- ---
<section class="section--tight quote" aria-labelledby="quote-text"> <section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
<div class="container"> <div class="container">
<figure class="quote__figure"> <div class="quote__frame">
<div class="quote__panel"> <div class="quote__panel">
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p> <div class="quote__stack" aria-live="off" data-live>
<blockquote> {items.map((item, i) => (
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p> <figure
</blockquote> class="quote__slide"
<figcaption class="quote__caption"> id={`quote-${i}`}
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p> role="group"
</figcaption> aria-roledescription="slide"
aria-label={ui.quote.slide(i + 1, items.length)}
data-slide
data-active={i === 0 ? '' : undefined}
>
<p class="eyebrow"><a href={item.href} class="quote__source">{item.eyebrow}</a></p>
<blockquote>
<p class="h2 quote__text">{quoted(item.text)}</p>
</blockquote>
<figcaption class="quote__caption">
<span class="quote__name">{item.name}</span><br /><span class="muted">{item.role}</span>
</figcaption>
</figure>
))}
</div>
<div class="quote__controls" data-controls hidden>
{items.map((item, i) => (
<button
type="button"
class="quote__pick"
aria-controls={`quote-${i}`}
aria-label={ui.quote.show(item.name)}
aria-current={i === 0 ? 'true' : undefined}
data-pick
>
<span class="quote__track" aria-hidden="true"><span class="quote__fill"></span></span>
</button>
))}
</div>
</div> </div>
<div class="quote__portrait"> <div class="quote__portraits">
<Picture {items.map((item, i) => (
src={felix} <div class="quote__portrait" data-portrait data-active={i === 0 ? '' : undefined}>
alt={ui.quote.portraitAlt} <Picture
formats={['avif']} src={item.portrait}
fallbackFormat="webp" alt={item.portraitAlt}
widths={[480, 640, 960, 1200]} formats={['avif']}
sizes="(min-width: 64rem) 40vw, 100vw" fallbackFormat="webp"
loading="lazy" widths={[480, 640, 960, 1200]}
decoding="async" sizes="(min-width: 64rem) 40vw, 100vw"
class="img-mono quote__img" loading="lazy"
pictureAttributes={{ class: 'quote__picture' }} decoding="async"
/> class="img-mono quote__img"
pictureAttributes={{ class: 'quote__picture' }}
/>
</div>
))}
</div> </div>
</figure> </div>
</div> </div>
</section> </section>
<style> <style>
.quote__figure { .quote {
/* One statement holds for this long before the next one comes in. */
--quote-hold: 9s;
}
.quote__frame {
display: grid; display: grid;
background: var(--paper-deep); background: var(--paper-deep);
} }
@@ -48,15 +91,183 @@ const essay = localePath(lang, '/insights/xg-aloe-user-forum');
align-content: space-between; align-content: space-between;
padding: var(--space-7) var(--space-6); padding: var(--space-7) var(--space-6);
} }
.quote__essay { text-decoration: none; }
.quote__essay:hover { text-decoration: underline; } /* Slides share one grid cell, so the frame takes the height of the longest and never jumps. */
.quote__stack, .quote__portraits { display: grid; }
.quote__slide, .quote__portrait { grid-area: 1 / 1; }
/*
* The change: the outgoing statement lifts away, then the incoming one rises in line by line,
* eyebrow, quote, caption. The new portrait fades in over the old one and settles from a slight
* scale, as the hero image does on load.
*/
.quote__slide {
display: grid;
gap: var(--space-7);
align-content: space-between;
margin: 0;
visibility: hidden;
transition: visibility 0s linear var(--dur-slow);
}
.quote__slide > * {
opacity: 0;
transform: translateY(var(--space-6));
transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}
.quote__slide[data-leaving] > * { transform: translateY(calc(-1 * var(--space-5))); }
.quote__slide > :nth-child(2) { --i: 1; }
.quote__slide > :nth-child(3) { --i: 2; }
.quote__slide[data-active] { visibility: visible; transition: visibility 0s; }
.quote__slide[data-active] > * {
opacity: 1;
transform: none;
transition-duration: var(--dur-rise);
transition-delay: calc(var(--dur-fast) + var(--i, 0) * var(--stagger-rise));
}
.quote__portraits { overflow: hidden; }
.quote__portrait {
opacity: 0;
visibility: hidden;
/* The old portrait stays put until the new one has covered it. */
transition: opacity 0s linear var(--dur-hero), visibility 0s linear var(--dur-hero);
}
.quote__portrait :global(img) {
transform: scale(1.06);
transition: transform 0s linear var(--dur-hero);
}
.quote__portrait[data-active] {
z-index: 1;
opacity: 1;
visibility: visible;
transition: opacity var(--dur-rise) var(--ease), visibility 0s;
}
.quote__portrait[data-active] :global(img) {
transform: none;
transition: transform var(--dur-hero) var(--ease);
}
.quote__source { text-decoration: none; }
.quote__source:hover { text-decoration: underline; }
.quote__text { text-wrap: balance; max-width: 16em; } .quote__text { text-wrap: balance; max-width: 16em; }
.quote__name { font-weight: var(--weight-medium); } .quote__name { font-weight: var(--weight-medium); }
/*
* Controls: the progress bar runs along the bottom edge of the text panel, one segment per
* statement, filling while it is shown. Each segment is a button with a taller hit area above it.
*/
.quote__panel { position: relative; }
.quote__controls {
position: absolute;
inset: auto 0 0 0;
z-index: 2; /* above the portrait, which on narrow screens starts right below the bar */
display: flex;
gap: var(--space-1);
}
.quote__controls[hidden] { display: none; }
.quote__pick {
display: flex;
flex-direction: column;
justify-content: end;
flex: 1 1 0;
min-width: 0;
height: var(--space-6);
padding: 0;
background: none;
border: 0;
cursor: pointer;
}
.quote__pick:focus-visible { outline-offset: calc(-1 * var(--focus-width)); }
.quote__pick:hover .quote__track { background: var(--ink-muted); }
.quote__track {
display: block;
height: calc(2 * var(--hairline));
transition: background-color var(--dur-fast) var(--ease);
background: var(--line);
overflow: hidden;
}
.quote__fill {
display: block;
height: 100%;
background: var(--ink);
/* A full-width bar slid in from the left, not a scaled one: translation stays on the
compositor and moves by sub-pixels, scaling from zero re-rasterises and steps. */
transform: translateX(-100%);
will-change: transform;
}
.quote__pick[aria-current] .quote__fill { transform: none; }
.quote[data-playing] .quote__pick[aria-current] .quote__fill {
animation: quote-fill var(--quote-hold) linear both;
}
.quote[data-playing]:is([data-held], [data-away]) .quote__pick[aria-current] .quote__fill { animation-play-state: paused; }
@keyframes quote-fill { from { transform: translateX(-100%); } to { transform: none; } }
:global(.quote__picture) { display: block; width: 100%; height: 100%; } :global(.quote__picture) { display: block; width: 100%; height: 100%; }
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; } .quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
@media (min-width: 64rem) { @media (min-width: 64rem) {
.quote__figure { grid-template-columns: 11fr 9fr; } .quote__frame { grid-template-columns: 11fr 9fr; }
.quote__panel { padding: var(--space-8) var(--space-8); } .quote__panel { padding: var(--space-8) var(--space-8); }
.quote__img, :global(.quote__img) { aspect-ratio: auto; } /* The image's own ratio once loaded, and the same square reserved before, so the lazy
portrait does not grow the section when it arrives. Both portraits are square. */
.quote__img, :global(.quote__img) { aspect-ratio: auto 1 / 1; }
}
@media (prefers-reduced-motion: reduce) {
.quote__slide, .quote__slide > *, .quote__portrait, .quote__portrait :global(img) {
transition: none !important;
transform: none !important;
}
} }
</style> </style>
<script>
/**
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
* the pointer or focus is inside or the section is off screen; picking a statement stops it.
*/
const root = document.querySelector<HTMLElement>('[data-quotes]');
if (root) {
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
const portraits = Array.from(root.querySelectorAll<HTMLElement>('[data-portrait]'));
const picks = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-pick]'));
const live = root.querySelector<HTMLElement>('[data-live]')!;
const controls = root.querySelector<HTMLElement>('[data-controls]')!;
const fills = picks.map((p) => p.querySelector<HTMLElement>('.quote__fill')!);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let index = 0;
let playing = false;
const show = (next: number) => {
next = (next + slides.length) % slides.length;
if (next === index) return;
// The outgoing statement lifts up instead of sinking back to where the incoming one rises from.
const leaving = slides[index];
leaving.setAttribute('data-leaving', '');
window.setTimeout(() => leaving.removeAttribute('data-leaving'), 1000);
index = next;
[slides, portraits].forEach((set) => set.forEach((el, i) => el.toggleAttribute('data-active', i === index)));
picks.forEach((p, i) => (i === index ? p.setAttribute('aria-current', 'true') : p.removeAttribute('aria-current')));
};
const setPlaying = (on: boolean) => {
playing = on;
root.toggleAttribute('data-playing', on);
live.setAttribute('aria-live', on ? 'off' : 'polite');
};
// The fill on the current pick is the timer: when its animation ends, the next statement comes.
fills.forEach((fill) => fill.addEventListener('animationend', () => { if (playing) show(index + 1); }));
picks.forEach((pick, i) => pick.addEventListener('click', () => { setPlaying(false); show(i); }));
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
root.addEventListener('pointerenter', () => hold(true));
root.addEventListener('pointerleave', () => hold(root.contains(document.activeElement)));
root.addEventListener('focusin', () => hold(true));
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(root.matches(':hover')); });
// Off screen, the clock stops, so a reader arriving from above starts on a fresh statement.
new IntersectionObserver(([entry]) => root.toggleAttribute('data-away', !entry.isIntersecting), { threshold: 0.4 }).observe(root);
controls.hidden = false;
setPlaying(!reduced);
}
</script>
+3 -2
View File
@@ -28,7 +28,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
tabindex={i === 0 ? undefined : -1} tabindex={i === 0 ? undefined : -1}
>{item.title}</button> >{item.title}</button>
</h3> </h3>
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0"> <div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0" data-open={i === 0 ? '' : undefined}>
<div class="sectors__panel-inner"> <div class="sectors__panel-inner">
<p class="muted">{item.body}</p> <p class="muted">{item.body}</p>
<Picture <Picture
@@ -104,7 +104,8 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
/* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */ /* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */
.sectors__media { display: none; } .sectors__media { display: none; }
/* With scripting, one panel at a time; without, everything is readable. */ /* With scripting, one panel at a time; without, everything is readable. The first panel is
open in the markup, so nothing expands on load and the page below it does not move. */
:global(html.js) .sectors__panel { :global(html.js) .sectors__panel {
display: grid; display: grid;
grid-template-rows: 0fr; grid-template-rows: 0fr;
+1 -2
View File
@@ -93,8 +93,7 @@ const now = 0;
position: absolute; position: absolute;
inset: 0.2rem; inset: 0.2rem;
border-radius: 50%; border-radius: 50%;
background: var(--signal); background: var(--ink);
outline: var(--hairline) solid var(--ink);
} }
.track__step[data-state='next'] .track__node { border-style: dashed; border-color: var(--ink-muted); } .track__step[data-state='next'] .track__node { border-style: dashed; border-color: var(--ink-muted); }
.track__state { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); } .track__state { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
+21 -3
View File
@@ -168,7 +168,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
padding: var(--space-6); padding: var(--space-6);
background: var(--glass-on-night); background: var(--panel-on-night);
align-content: start; align-content: start;
justify-items: start; justify-items: start;
} }
@@ -210,7 +210,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
letter-spacing: var(--track-caps); letter-spacing: var(--track-caps);
text-transform: uppercase; text-transform: uppercase;
line-height: var(--leading-small); line-height: var(--leading-small);
background: var(--glass-on-night); background: var(--panel-on-night);
} }
.readout__cell { .readout__cell {
display: grid; display: grid;
@@ -221,7 +221,23 @@ const ridges = Array.from({ length: rows }, (_, r) => {
.readout__cell + .readout__cell { border-top: var(--hairline) solid var(--line-on-night); } .readout__cell + .readout__cell { border-top: var(--hairline) solid var(--line-on-night); }
.readout__cell--head, .readout dt { color: var(--ink-muted); } .readout__cell--head, .readout dt { color: var(--ink-muted); }
.readout dd { display: flex; align-items: center; gap: var(--space-2); } .readout dd { display: flex; align-items: center; gap: var(--space-2); }
.readout__live { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--signal); flex: none; } .readout__live { position: relative; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--signal); flex: none; }
/* The live phase pulses: a ring in the signal tone grows out of the dot and fades. */
.readout__live::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--signal);
animation: readout-pulse calc(6 * var(--dur-slow)) var(--ease) infinite;
}
@keyframes readout-pulse {
from { transform: scale(1); opacity: 0.7; }
70%, to { transform: scale(3); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.readout__live::after { animation: none; opacity: 0; }
}
@media (max-width: 30rem) { @media (max-width: 30rem) {
.signup { padding: var(--space-5); } .signup { padding: var(--space-5); }
@@ -242,5 +258,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
.shero__name { grid-column: 1 / -1; margin-top: var(--space-4); } .shero__name { grid-column: 1 / -1; margin-top: var(--space-4); }
.readout__cell { grid-template-columns: 1fr; gap: var(--space-1); padding: var(--space-4) var(--space-5); } .readout__cell { grid-template-columns: 1fr; gap: var(--space-1); padding: var(--space-4) var(--space-5); }
.readout__cell + .readout__cell { border-top: 0; border-left: var(--hairline) solid var(--line-on-night); } .readout__cell + .readout__cell { border-top: 0; border-left: var(--hairline) solid var(--line-on-night); }
/* The strip's title sits in the middle of its cell, apart from the label/value cells beside it. */
.readout__cell--head { place-content: center; text-align: center; }
} }
</style> </style>
+55 -13
View File
@@ -100,12 +100,25 @@ const en = {
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' }, partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
}, },
quote: { quote: {
eyebrow: 'From the essay', label: 'Statements from the founders',
text: 'Standard-compliant is not the same as secure.', show: (name: string) => `Show the statement by ${name}`,
name: 'Dr. Felix Klement', slide: (i: number, n: number) => `${i} of ${n}`,
role: 'Founder and CEO', items: [
link: 'Read the essay', {
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.', eyebrow: 'From the essay “From Open Interfaces to Open Risks”',
text: 'Standard-compliant is not the same as secure.',
name: 'Dr. Felix Klement',
role: 'Founder and CEO',
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
},
{
eyebrow: 'On hardware roots of trust',
text: 'Software is only as trustworthy as the hardware it runs on.',
name: 'Dr. Florian Frank',
role: 'Founder and CTO',
portraitAlt: 'Portrait of Dr. Florian Frank, founder and CTO of CipherCell.',
},
],
}, },
insights: { insights: {
title: 'Insights', title: 'Insights',
@@ -143,6 +156,14 @@ const en = {
required: 'Required.', required: 'Required.',
honeypot: 'Leave this field empty', honeypot: 'Leave this field empty',
submit: 'Send message', submit: 'Send message',
sent: 'Message sent. We respond within 24 hours.',
invalid: 'Please fill in every field and give a valid email address.',
failed: 'Your message did not go through. Please try again, or write to office@ciphercell.de.',
toast: { sending: 'Sending', sent: 'Sent', failed: 'Not sent', close: 'Close' },
},
result: {
sent: { title: 'Message sent.', body: 'Thank you. We respond within 24 hours.', link: 'Go to the start page' },
error: { title: 'Message not sent.', body: 'Your message did not go through. Please try again, or write to office@ciphercell.de.', link: 'Return to the form' },
}, },
}, },
footer: { footer: {
@@ -275,7 +296,7 @@ const en = {
}, },
}, },
team: { team: {
title: ['Researchers,', 'engineers,', 'architects.'], title: ['Engineers,', 'architects,', 'researchers.'],
intro: 'The people who go deep on the problems that matter, from network, software to hardware and cloud-native systems. If it\'s a genuinely hard problem, we\'re in our element.', intro: 'The people who go deep on the problems that matter, from network, software to hardware and cloud-native systems. If it\'s a genuinely hard problem, we\'re in our element.',
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.', photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
roles: { roles: {
@@ -394,12 +415,25 @@ const de: typeof en = {
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' }, partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
}, },
quote: { quote: {
eyebrow: 'Aus dem Essay', label: 'Aussagen der Gründer',
text: 'Standardkonform ist nicht dasselbe wie sicher.', show: (name: string) => `Aussage von ${name} zeigen`,
name: 'Dr. Felix Klement', slide: (i: number, n: number) => `${i} von ${n}`,
role: 'Gründer und CEO', items: [
link: 'Essay lesen', {
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.', eyebrow: 'Aus dem Essay „From Open Interfaces to Open Risks“',
text: 'Standardkonform ist nicht dasselbe wie sicher.',
name: 'Dr. Felix Klement',
role: 'Gründer und CEO',
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
},
{
eyebrow: 'Über Vertrauensanker in der Hardware',
text: 'Software ist nur so vertrauenswürdig wie die Hardware, auf der sie läuft.',
name: 'Dr. Florian Frank',
role: 'Gründer und CTO',
portraitAlt: 'Porträt von Dr. Florian Frank, Gründer und CTO von CipherCell.',
},
],
}, },
insights: { insights: {
title: 'Einblicke', title: 'Einblicke',
@@ -437,6 +471,14 @@ const de: typeof en = {
required: 'Pflichtfeld.', required: 'Pflichtfeld.',
honeypot: 'Dieses Feld bitte leer lassen', honeypot: 'Dieses Feld bitte leer lassen',
submit: 'Nachricht senden', submit: 'Nachricht senden',
sent: 'Nachricht gesendet. Wir antworten innerhalb von 24 Stunden.',
invalid: 'Bitte füllen Sie alle Felder aus und geben Sie eine gültige E-Mail-Adresse an.',
failed: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
toast: { sending: 'Wird gesendet', sent: 'Gesendet', failed: 'Nicht gesendet', close: 'Schließen' },
},
result: {
sent: { title: 'Nachricht gesendet.', body: 'Vielen Dank. Wir antworten innerhalb von 24 Stunden.', link: 'Zur Startseite' },
error: { title: 'Nachricht nicht gesendet.', body: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.', link: 'Zurück zum Formular' },
}, },
}, },
footer: { footer: {
+8 -2
View File
@@ -9,10 +9,15 @@ interface Props {
description: string; description: string;
lang?: Lang; lang?: Lang;
ogImage?: string; ogImage?: string;
/** Pages that only make sense as the answer to an action, such as the contact form result. */
noindex?: boolean;
/** The surface the page opens on. 'night' for pages that start on a dark hero, so the header
is drawn in its night tone from the first frame instead of switching after load. */
tone?: 'paper' | 'night';
} }
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url); const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
const { title, description } = Astro.props; const { title, description, noindex, tone = 'paper' } = Astro.props;
const ui = t(lang); const ui = t(lang);
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`; const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
const canonical = new URL(Astro.url.pathname, Astro.site); const canonical = new URL(Astro.url.pathname, Astro.site);
@@ -26,6 +31,7 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{fullTitle}</title> <title>{fullTitle}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
{noindex && <meta name="robots" content="noindex" />}
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<meta name="theme-color" content="#E9E5DF" /> <meta name="theme-color" content="#E9E5DF" />
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
@@ -45,7 +51,7 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
</head> </head>
<body> <body>
<a class="skip-link" href="#main">{ui.nav.skip}</a> <a class="skip-link" href="#main">{ui.nav.skip}</a>
<Header lang={lang} /> <Header lang={lang} tone={tone} />
<main id="main" tabindex="-1"> <main id="main" tabindex="-1">
<slot /> <slot />
</main> </main>
+29
View File
@@ -0,0 +1,29 @@
---
import Base from '../layouts/Base.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; outcome: 'sent' | 'error' }
const { lang, outcome } = Astro.props;
const r = t(lang).contact.result[outcome];
// After an error the way forward is the form again; after sending it is the rest of the site.
const href = outcome === 'sent' ? localePath(lang, '/') : `${localePath(lang, '/')}#contact`;
---
<Base lang={lang} title={r.title} description={r.body} noindex>
<section class="section">
<div class="container grid">
<h1 class="display result__title">{r.title}</h1>
<div class="result__body">
<p class="muted">{r.body}</p>
<a class="link" href={href}>{r.link}</a>
</div>
</div>
</section>
</Base>
<style>
.result__title { grid-column: 1 / -1; }
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); }
@media (min-width: 48rem) {
.result__title { grid-column: 1 / span 8; }
.result__body { grid-column: 1 / span 5; }
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@ interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
--- ---
<Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description}> <Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description} tone="night">
<Hero lang={lang} /> <Hero lang={lang} />
<Manifesto lang={lang} /> <Manifesto lang={lang} />
<Method lang={lang} /> <Method lang={lang} />
+1 -1
View File
@@ -15,7 +15,7 @@ interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
--- ---
<Base lang={lang} title={ui.meta.verifier.title} description={ui.meta.verifier.description}> <Base lang={lang} title={ui.meta.verifier.title} description={ui.meta.verifier.description} tone="night">
{verifierComingSoon ? ( {verifierComingSoon ? (
<> <>
<SoonHero lang={lang} /> <SoonHero lang={lang} />
+4
View File
@@ -0,0 +1,4 @@
---
import ContactResult from '../_ContactResult.astro';
---
<ContactResult lang="en" outcome="error" />
+4
View File
@@ -0,0 +1,4 @@
---
import ContactResult from '../_ContactResult.astro';
---
<ContactResult lang="en" outcome="sent" />
+4
View File
@@ -0,0 +1,4 @@
---
import ContactResult from '../../_ContactResult.astro';
---
<ContactResult lang="de" outcome="error" />
+4
View File
@@ -0,0 +1,4 @@
---
import ContactResult from '../../_ContactResult.astro';
---
<ContactResult lang="de" outcome="sent" />
+9
View File
@@ -41,6 +41,8 @@
--field-sky: #6AAEE8; --field-sky: #6AAEE8;
/* Darkens the lower edge of a field so paper-coloured type on it holds 3:1 or better. */ /* Darkens the lower edge of a field so paper-coloured type on it holds 3:1 or better. */
--field-scrim: linear-gradient(to top, rgb(14 16 15 / 0.55), rgb(14 16 15 / 0) 65%); --field-scrim: linear-gradient(to top, rgb(14 16 15 / 0.55), rgb(14 16 15 / 0) 65%);
/* The same for a field whose text sits centred on the left (the home page Insights cards). */
--field-scrim-centre: radial-gradient(90% 85% at 20% 50%, rgb(14 16 15 / 0.72), rgb(14 16 15 / 0) 100%);
/* Signal: the one status tone the brief allows. Used only for a live state (the coming-soon /* Signal: the one status tone the brief allows. Used only for a live state (the coming-soon
page's current phase), at most twice per page. Never for text, links or decoration. */ page's current phase), at most twice per page. Never for text, links or decoration. */
@@ -121,6 +123,8 @@
/* Motion */ /* Motion */
--ease: cubic-bezier(0.2, 0, 0, 1); --ease: cubic-bezier(0.2, 0, 0, 1);
/* A small overshoot, for the one element that pops up: the contact form's toast. */
--ease-pop: cubic-bezier(0.34, 1.4, 0.64, 1);
--dur-fast: 180ms; --dur-fast: 180ms;
--dur: 240ms; --dur: 240ms;
--dur-slow: 320ms; --dur-slow: 320ms;
@@ -128,6 +132,8 @@
--dur-rise: 700ms; /* a headline line rising in on load */ --dur-rise: 700ms; /* a headline line rising in on load */
--delay-rise: 240ms; /* before the first line */ --delay-rise: 240ms; /* before the first line */
--stagger-rise: 160ms; /* between lines */ --stagger-rise: 160ms; /* between lines */
--dur-toast: 6000ms; /* a success toast stays this long */
--dur-toast-error: 9000ms; /* an error toast, which has more to read */
/* Chrome */ /* Chrome */
--header-height: 4.5rem; --header-height: 4.5rem;
@@ -142,6 +148,9 @@
--glass-on-night: rgb(14 16 15 / 0.56); --glass-on-night: rgb(14 16 15 / 0.56);
--glass-solid: rgb(233 229 223 / 0.96); --glass-solid: rgb(233 229 223 / 0.96);
--glass-solid-on-night: rgb(14 16 15 / 0.94); --glass-solid-on-night: rgb(14 16 15 / 0.94);
/* Panels over a busy night background (the product page's sign-up and status strip): darker
than the menu glass, so the lines behind only just show through. */
--panel-on-night: rgb(14 16 15 / 0.85);
--glass-blur: 14px; --glass-blur: 14px;
/* Tints mixed from the text colour, so they work on either tone. */ /* Tints mixed from the text colour, so they work on either tone. */
--tint-hover: 6%; --tint-hover: 6%;