asdf
This commit is contained in:
1 parent
cc41849cf5
commit
f84687d745
22 files changed
+555
-14
No files matched your search
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user