This commit is contained in:
Felix Klement committed 2026-10-04 22:29:17 +02:00
1 parent cc41849cf5
commit f84687d745
22 files changed
+554 -13

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/node_modules
.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 . .
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
+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**
(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
View File
@@ -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 } } } },
});
+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.
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
View File
@@ -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",
+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"
}
}
+187 -1
View File
@@ -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 left. 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>
+3 -1
View File
@@ -207,7 +207,9 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
@media (min-width: 64rem) {
.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) {
+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}
>{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;
+17 -1
View File
@@ -156,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: {
@@ -288,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: {
@@ -463,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: {
+4 -1
View File
@@ -9,10 +9,12 @@ 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;
}
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
const { title, description } = Astro.props;
const { title, description, noindex } = 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 +28,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} />
+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>
+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" />
+4
View File
@@ -121,6 +121,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 +130,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;