Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7508f6e084 | ||
|
|
f84687d745 | ||
|
|
cc41849cf5 | ||
|
|
b195c9e666 | ||
|
|
3e6cfcbe35 |
No files matched your search
@@ -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
|
||||
@@ -10,3 +10,7 @@ ds-bundle/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
.DS_Store
|
||||
# Local secrets for the contact handler
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
+12
-1
@@ -10,10 +10,21 @@ RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
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
|
||||
RUN apk add --no-cache nodejs
|
||||
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=contact /srv/contact /srv/contact
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1/healthz || exit 1
|
||||
@@ -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**
|
||||
(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
|
||||
|
||||
@@ -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/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. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
@@ -56,9 +58,33 @@ TLS is handled by Dokploy's Traefik.
|
||||
|
||||
## Things the host has to provide
|
||||
|
||||
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` and a honeypot
|
||||
field `website` to `POST /contact`, as the current site does. The handler is server-side and
|
||||
not part of this static build.
|
||||
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
|
||||
honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs`
|
||||
(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`
|
||||
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
|
||||
|
||||
+3
-1
@@ -5,11 +5,13 @@ import sitemap from '@astrojs/sitemap';
|
||||
export default defineConfig({
|
||||
site: 'https://www.ciphercell.de',
|
||||
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: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'de'],
|
||||
routing: { prefixDefaultLocale: false },
|
||||
},
|
||||
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 } } } },
|
||||
});
|
||||
Executable
+15
@@ -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;'
|
||||
@@ -7,6 +7,13 @@ server {
|
||||
|
||||
# TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https.
|
||||
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;
|
||||
|
||||
gzip on;
|
||||
@@ -24,6 +31,24 @@ server {
|
||||
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.
|
||||
location ~ ^(.+)/$ {
|
||||
return 301 $1$is_args$args;
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@
|
||||
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
|
||||
"build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
|
||||
"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": {
|
||||
"@fontsource-variable/inter-tight": "^5.3.0",
|
||||
|
||||
@@ -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}`));
|
||||
Generated
+25
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
import Wordmark from './Wordmark.astro';
|
||||
import { localePath, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
interface Props { lang: Lang; tone?: 'paper' | 'night' }
|
||||
const { lang, tone = 'paper' } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const home = localePath(lang, '/');
|
||||
const items = [
|
||||
@@ -16,7 +16,7 @@ const contactHref = `${home}#contact`;
|
||||
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||
const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#');
|
||||
---
|
||||
<header class="header" data-header data-tone="paper">
|
||||
<header class="header" data-header data-tone={tone} data-tone-init>
|
||||
<div class="container header__shell">
|
||||
<div class="header__bar hud-frame">
|
||||
<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);
|
||||
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)) {
|
||||
.header__bar::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
|
||||
.night section crosses it. An IntersectionObserver, so nothing runs on scroll. */
|
||||
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) {
|
||||
const under = new Set<Element>();
|
||||
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);
|
||||
}
|
||||
header.dataset.tone = under.size ? 'night' : 'paper';
|
||||
if (header.hasAttribute('data-tone-init')) settle();
|
||||
}, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` });
|
||||
sections.forEach((section) => observer!.observe(section));
|
||||
};
|
||||
@@ -263,5 +272,7 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
|
||||
window.clearTimeout(resize);
|
||||
resize = window.setTimeout(watch, 150);
|
||||
});
|
||||
} else {
|
||||
settle();
|
||||
}
|
||||
</script>
|
||||
@@ -16,7 +16,17 @@ const f = ui.contact.form;
|
||||
</address>
|
||||
</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">
|
||||
<label for="website">{f.honeypot}</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
||||
@@ -43,6 +53,22 @@ const f = ui.contact.form;
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
@@ -89,6 +115,107 @@ const f = ui.contact.form;
|
||||
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) {
|
||||
.contact__text { grid-column: 1 / span 5; }
|
||||
.contact__form { grid-column: 7 / span 6; }
|
||||
@@ -98,3 +225,62 @@ const f = ui.contact.form;
|
||||
.field { grid-column: 1 / -1; }
|
||||
}
|
||||
</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>
|
||||
@@ -69,16 +69,21 @@ const m = ui.insights.mention;
|
||||
height: 100%;
|
||||
}
|
||||
.insights__card:hover h3 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; }
|
||||
.insights__img, :global(.insights__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; margin-bottom: var(--space-2); }
|
||||
.insights__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; }
|
||||
/* 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) {
|
||||
aspect-ratio: 3 / 2;
|
||||
--field-scrim: var(--field-scrim-centre);
|
||||
aspect-ratio: 4 / 1;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--step-title);
|
||||
line-height: var(--leading-title);
|
||||
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) {
|
||||
.insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@@ -74,15 +74,39 @@ const logos = await Promise.all([
|
||||
display: grid;
|
||||
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 {
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: var(--space-7);
|
||||
min-height: calc(var(--space-9) * 2.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__name { font-weight: var(--weight-medium); }
|
||||
.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__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)); border-bottom: var(--hairline) solid var(--line); }
|
||||
.partners__item { border-bottom: 0; }
|
||||
.partners__item + .partners__item { border-left: var(--hairline) solid var(--line); }
|
||||
.partners__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.partners__item + .partners__item { border-top: 0; border-left: var(--hairline) solid var(--line); }
|
||||
}
|
||||
</style>
|
||||
+240
-29
@@ -1,44 +1,87 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import felix from '../../assets/images/people/felix.png';
|
||||
import florian from '../../assets/images/people/florian.png';
|
||||
import { localePath, t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
||||
// 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">
|
||||
<figure class="quote__figure">
|
||||
<div class="quote__frame">
|
||||
<div class="quote__panel">
|
||||
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p>
|
||||
<blockquote>
|
||||
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p>
|
||||
</blockquote>
|
||||
<figcaption class="quote__caption">
|
||||
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p>
|
||||
</figcaption>
|
||||
<div class="quote__stack" aria-live="off" data-live>
|
||||
{items.map((item, i) => (
|
||||
<figure
|
||||
class="quote__slide"
|
||||
id={`quote-${i}`}
|
||||
role="group"
|
||||
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 class="quote__portrait">
|
||||
<Picture
|
||||
src={felix}
|
||||
alt={ui.quote.portraitAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 640, 960, 1200]}
|
||||
sizes="(min-width: 64rem) 40vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="img-mono quote__img"
|
||||
pictureAttributes={{ class: 'quote__picture' }}
|
||||
/>
|
||||
<div class="quote__portraits">
|
||||
{items.map((item, i) => (
|
||||
<div class="quote__portrait" data-portrait data-active={i === 0 ? '' : undefined}>
|
||||
<Picture
|
||||
src={item.portrait}
|
||||
alt={item.portraitAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 640, 960, 1200]}
|
||||
sizes="(min-width: 64rem) 40vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="img-mono quote__img"
|
||||
pictureAttributes={{ class: 'quote__picture' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.quote__figure {
|
||||
.quote {
|
||||
/* One statement holds for this long before the next one comes in. */
|
||||
--quote-hold: 9s;
|
||||
}
|
||||
.quote__frame {
|
||||
display: grid;
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
@@ -48,15 +91,183 @@ const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
||||
align-content: space-between;
|
||||
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__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%; }
|
||||
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
|
||||
@media (min-width: 64rem) {
|
||||
.quote__figure { grid-template-columns: 11fr 9fr; }
|
||||
.quote__frame { grid-template-columns: 11fr 9fr; }
|
||||
.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>
|
||||
|
||||
<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>
|
||||
@@ -28,7 +28,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
|
||||
tabindex={i === 0 ? undefined : -1}
|
||||
>{item.title}</button>
|
||||
</h3>
|
||||
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0">
|
||||
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0" data-open={i === 0 ? '' : undefined}>
|
||||
<div class="sectors__panel-inner">
|
||||
<p class="muted">{item.body}</p>
|
||||
<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. */
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
|
||||
@@ -93,8 +93,7 @@ const now = 0;
|
||||
position: absolute;
|
||||
inset: 0.2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--signal);
|
||||
outline: var(--hairline) solid var(--ink);
|
||||
background: var(--ink);
|
||||
}
|
||||
.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); }
|
||||
|
||||
@@ -168,7 +168,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-6);
|
||||
background: var(--glass-on-night);
|
||||
background: var(--panel-on-night);
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
}
|
||||
@@ -210,7 +210,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
|
||||
letter-spacing: var(--track-caps);
|
||||
text-transform: uppercase;
|
||||
line-height: var(--leading-small);
|
||||
background: var(--glass-on-night);
|
||||
background: var(--panel-on-night);
|
||||
}
|
||||
.readout__cell {
|
||||
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--head, .readout dt { color: var(--ink-muted); }
|
||||
.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) {
|
||||
.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); }
|
||||
.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); }
|
||||
/* 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>
|
||||
+55
-13
@@ -100,12 +100,25 @@ const en = {
|
||||
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
|
||||
},
|
||||
quote: {
|
||||
eyebrow: 'From the essay',
|
||||
text: 'Standard-compliant is not the same as secure.',
|
||||
name: 'Dr. Felix Klement',
|
||||
role: 'Founder and CEO',
|
||||
link: 'Read the essay',
|
||||
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
|
||||
label: 'Statements from the founders',
|
||||
show: (name: string) => `Show the statement by ${name}`,
|
||||
slide: (i: number, n: number) => `${i} of ${n}`,
|
||||
items: [
|
||||
{
|
||||
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: {
|
||||
title: 'Insights',
|
||||
@@ -143,6 +156,14 @@ const en = {
|
||||
required: 'Required.',
|
||||
honeypot: 'Leave this field empty',
|
||||
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: {
|
||||
@@ -275,7 +296,7 @@ const en = {
|
||||
},
|
||||
},
|
||||
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.',
|
||||
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
|
||||
roles: {
|
||||
@@ -394,12 +415,25 @@ const de: typeof en = {
|
||||
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
|
||||
},
|
||||
quote: {
|
||||
eyebrow: 'Aus dem Essay',
|
||||
text: 'Standardkonform ist nicht dasselbe wie sicher.',
|
||||
name: 'Dr. Felix Klement',
|
||||
role: 'Gründer und CEO',
|
||||
link: 'Essay lesen',
|
||||
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
|
||||
label: 'Aussagen der Gründer',
|
||||
show: (name: string) => `Aussage von ${name} zeigen`,
|
||||
slide: (i: number, n: number) => `${i} von ${n}`,
|
||||
items: [
|
||||
{
|
||||
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: {
|
||||
title: 'Einblicke',
|
||||
@@ -437,6 +471,14 @@ const de: typeof en = {
|
||||
required: 'Pflichtfeld.',
|
||||
honeypot: 'Dieses Feld bitte leer lassen',
|
||||
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: {
|
||||
|
||||
@@ -9,10 +9,15 @@ interface Props {
|
||||
description: string;
|
||||
lang?: Lang;
|
||||
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 { title, description } = Astro.props;
|
||||
const { title, description, noindex, tone = 'paper' } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
@@ -26,6 +31,7 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
{noindex && <meta name="robots" content="noindex" />}
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#E9E5DF" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
@@ -45,7 +51,7 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">{ui.nav.skip}</a>
|
||||
<Header lang={lang} />
|
||||
<Header lang={lang} tone={tone} />
|
||||
<main id="main" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
@@ -15,7 +15,7 @@ interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description}>
|
||||
<Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description} tone="night">
|
||||
<Hero lang={lang} />
|
||||
<Manifesto lang={lang} />
|
||||
<Method lang={lang} />
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
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 ? (
|
||||
<>
|
||||
<SoonHero lang={lang} />
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import ContactResult from '../_ContactResult.astro';
|
||||
---
|
||||
<ContactResult lang="en" outcome="error" />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import ContactResult from '../_ContactResult.astro';
|
||||
---
|
||||
<ContactResult lang="en" outcome="sent" />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import ContactResult from '../../_ContactResult.astro';
|
||||
---
|
||||
<ContactResult lang="de" outcome="error" />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import ContactResult from '../../_ContactResult.astro';
|
||||
---
|
||||
<ContactResult lang="de" outcome="sent" />
|
||||
@@ -41,6 +41,8 @@
|
||||
--field-sky: #6AAEE8;
|
||||
/* 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%);
|
||||
/* 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
|
||||
page's current phase), at most twice per page. Never for text, links or decoration. */
|
||||
@@ -121,6 +123,8 @@
|
||||
|
||||
/* Motion */
|
||||
--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: 240ms;
|
||||
--dur-slow: 320ms;
|
||||
@@ -128,6 +132,8 @@
|
||||
--dur-rise: 700ms; /* a headline line rising in on load */
|
||||
--delay-rise: 240ms; /* before the first line */
|
||||
--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 */
|
||||
--header-height: 4.5rem;
|
||||
@@ -142,6 +148,9 @@
|
||||
--glass-on-night: rgb(14 16 15 / 0.56);
|
||||
--glass-solid: rgb(233 229 223 / 0.96);
|
||||
--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;
|
||||
/* Tints mixed from the text colour, so they work on either tone. */
|
||||
--tint-hover: 6%;
|
||||
|
||||
Reference in new issue
Block a user