diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07c2bfc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index a42fcf0..01b1db7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,8 @@ ds-bundle/ .design-sync/.cache/ .design-sync/learnings/ .design-sync/node_modules -.DS_Store \ No newline at end of file +.DS_Store +# Local secrets for the contact handler +.env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile index 9e24cae..021b0d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 08c2434..11322b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index fa37156..872439f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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 } } } }, }); diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..497ebed --- /dev/null +++ b/docker/entrypoint.sh @@ -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;' diff --git a/docker/nginx.conf b/docker/nginx.conf index e7ed5a4..86097b7 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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; diff --git a/package.json b/package.json index b7e96de..8bc158c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/contact.mjs b/server/contact.mjs new file mode 100644 index 0000000..29ed805 --- /dev/null +++ b/server/contact.mjs @@ -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}`)); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..2a45228 --- /dev/null +++ b/server/package-lock.json @@ -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" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..26627aa --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/src/components/home/Contact.astro b/src/components/home/Contact.astro index eff3b96..83e251c 100644 --- a/src/components/home/Contact.astro +++ b/src/components/home/Contact.astro @@ -16,7 +16,17 @@ const f = ui.contact.form; -
+ +
+ + +
+
+

+ +
+

+ +
+ + diff --git a/src/components/home/Quote.astro b/src/components/home/Quote.astro index 4eb9fdf..9b4e0a8 100644 --- a/src/components/home/Quote.astro +++ b/src/components/home/Quote.astro @@ -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) { diff --git a/src/components/home/Sectors.astro b/src/components/home/Sectors.astro index 12f2a50..647783e 100644 --- a/src/components/home/Sectors.astro +++ b/src/components/home/Sectors.astro @@ -28,7 +28,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id tabindex={i === 0 ? undefined : -1} >{item.title} -
+

{item.body}

({ ...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; diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index 5de6026..4259cae 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -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: { diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index adda726..708e103 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -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); {fullTitle} + {noindex && } diff --git a/src/pages/_ContactResult.astro b/src/pages/_ContactResult.astro new file mode 100644 index 0000000..2bdb3c7 --- /dev/null +++ b/src/pages/_ContactResult.astro @@ -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`; +--- + +
+
+

{r.title}

+
+

{r.body}

+ {r.link} +
+
+
+ + + diff --git a/src/pages/contact/error.astro b/src/pages/contact/error.astro new file mode 100644 index 0000000..eeeb6c7 --- /dev/null +++ b/src/pages/contact/error.astro @@ -0,0 +1,4 @@ +--- +import ContactResult from '../_ContactResult.astro'; +--- + diff --git a/src/pages/contact/sent.astro b/src/pages/contact/sent.astro new file mode 100644 index 0000000..a9dbdc7 --- /dev/null +++ b/src/pages/contact/sent.astro @@ -0,0 +1,4 @@ +--- +import ContactResult from '../_ContactResult.astro'; +--- + diff --git a/src/pages/de/contact/error.astro b/src/pages/de/contact/error.astro new file mode 100644 index 0000000..0457547 --- /dev/null +++ b/src/pages/de/contact/error.astro @@ -0,0 +1,4 @@ +--- +import ContactResult from '../../_ContactResult.astro'; +--- + diff --git a/src/pages/de/contact/sent.astro b/src/pages/de/contact/sent.astro new file mode 100644 index 0000000..eb46299 --- /dev/null +++ b/src/pages/de/contact/sent.astro @@ -0,0 +1,4 @@ +--- +import ContactResult from '../../_ContactResult.astro'; +--- + diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f200c3d..3592552 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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;