Compare commits

..
6 Commits
Author SHA1 Message Date
Felix Klement 327f53922d Fix quote 2026-10-17 18:07:08 +02:00
Felix Klement d1a9214ffd Remove "else" in landing hero 2026-10-17 09:37:00 +02:00
Felix Klement 5b02ee8072 Small formatting fix in privacy 2026-10-12 10:52:54 +02:00
Felix Klement 5da9e161f8 Update imprint + privacy 2026-10-12 10:34:44 +02:00
Felix Klement 97e219456d Refactoring 2026-10-09 14:28:57 +02:00
Felix Klement fd58a8d32f Initial commit 🚀 2026-10-09 14:00:40 +02:00
117 changed files with 8534 additions and 1054 deletions

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -11,7 +11,7 @@
the point — the visual truth stays in the site's stylesheet, so the design system cannot the point — the visual truth stays in the site's stylesheet, so the design system cannot
drift from the site even as `base.css` changes, and each component's markup ports back to drift from the site even as `base.css` changes, and each component's markup ports back to
`.astro` one to one (every `<Name>.md` states the Astro equivalent). `.astro` one to one (every `<Name>.md` states the Astro equivalent).
- **Do not port the page-level `.astro` components** (Hero, Stages, HowItWorks, GridObject). - **Do not port the page-level `.astro` components** (Hero, HowItWorks, GridObject).
Their styles are scoped inside the `.astro` files, so a React copy would duplicate CSS and Their styles are scoped inside the `.astro` files, so a React copy would duplicate CSS and
drift. The line is: global primitive classes are fair game, component-scoped styles are not. drift. The line is: global primitive classes are fair game, component-scoped styles are not.
- `Stack` is the one component with no class behind it. Nothing in this system carries its own - `Stack` is the one component with no class behind it. Nothing in this system carries its own
+19
View File
@@ -0,0 +1,19 @@
.git
.gitignore
.DS_Store
**/.DS_Store
node_modules
dist
.astro
product_repo
.ds-sync
ds-bundle
.design-sync/.cache
.design-sync/learnings
.design-sync/node_modules
docs
Dockerfile
.dockerignore
*.log
.env
.env.*
+16
View File
@@ -0,0 +1,16 @@
# 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
# Signing secrets for the form's proof-of-work challenge (ALTCHA). Optional: without them each
# start makes its own. Generate with: openssl rand -hex 32
ALTCHA_HMAC_SECRET=
ALTCHA_HMAC_KEY_SECRET=
# Signs the alpha sign-up confirmation links (openssl rand -hex 32). Set it, or links sent
# before a restart stop working.
NOTIFY_SECRET=
# Where confirmation links point. Default: https://www.ciphercell.de. Locally, uncomment:
# SITE_URL=http://localhost:4321
+6
View File
@@ -9,3 +9,9 @@ ds-bundle/
.design-sync/.cache/ .design-sync/.cache/
.design-sync/learnings/ .design-sync/learnings/
.design-sync/node_modules .design-sync/node_modules
.DS_Store
# Local secrets for the contact handler
.env
.env.*
!.env.example
__pycache__/
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# Build the static site.
FROM node:22-bookworm-slim AS build
WORKDIR /app
ENV ASTRO_TELEMETRY_DISABLED=1 \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# The form handler and its production dependencies (nodemailer, altcha-lib).
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 ./
COPY server/mail ./mail
# 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
+139 -44
View File
@@ -13,6 +13,22 @@ npm run preview # serve dist/
npm run check # Astro + TypeScript check npm run check # Astro + TypeScript check
``` ```
## Docker / Dokploy
The `Dockerfile` builds the site with Node and serves `dist/` from nginx on port 80
(config in `docker/nginx.conf`: clean URLs without trailing slash, 404 page, gzip, long caching for
`/_astro/`, health check at `/healthz`).
```sh
docker build -t ciphercell .
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. The contact form needs the SMTP variables under
Environment; see *Contact form* below.
## Where things live ## Where things live
| Path | Purpose | | Path | Purpose |
@@ -22,16 +38,18 @@ npm run check # Astro + TypeScript check
| `src/i18n/ui.ts` | All interface copy, English and German, one object per language with the same shape. | | `src/i18n/ui.ts` | All interface copy, English and German, one object per language with the same shape. |
| `src/data/team.ts` | Team members, roles, portraits, links. | | `src/data/team.ts` | Team members, roles, portraits, links. |
| `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. | | `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. |
| `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. | | `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Sectors, Partners, Quote, Insights, Contact. |
| `src/components/home/GridObject.astro` | The wireframe objects on the stage and product cards (sphere, cube, plane, wave, steps, nested, stack). Geometry is computed at build time; single stroke in `--ink`. Each drawing is laid out by hand, so the `viewBox` is derived from the points as they are emitted, not assumed: a square centred on the drawing, padded for the stroke. A new kind can therefore never be clipped, and the card gives the drawing the flexible row so it sits centred between title and body. Each drawing is emitted in two groups, the lines that carry its idea and the grid around them, which is what the hover and the reveal act on. | | `src/components/verifier/GridObject.astro` | The wireframe objects on the product cards (steps, nested, stack). Geometry is computed at build time; single stroke in `--ink`. Each drawing is laid out by hand, so the `viewBox` is derived from the points as they are emitted, not assumed: a square centred on the drawing, padded for the stroke. A new kind can therefore never be clipped, and the card gives the drawing the flexible row so it sits centred between title and body. Each drawing is emitted in two groups, the lines that carry its idea and the grid around them, which is what the hover acts on. |
| `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. | | `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. |
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. | | `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards (with `GridObject`), 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. | | `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. |
| `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`. | | `server/contact.mjs`, `server/mail/` | The handler for both forms (contact, alpha sign-up): validates, checks the proof of work, rate-limits and sends by SMTP. `server/mail/confirm.mjs` lays out the confirmation email (tables, inline styles, logo as an inline attachment). Its own `package.json`, so the image only carries nodemailer and altcha-lib. |
| `scripts/*.mjs` | Review tooling (Playwright): `shot.mjs` (full page), `scroll-shots.mjs` and `stack-shots.mjs` (viewport by viewport), `motion-shots.mjs` (a draw-in over time), `hover-shot.mjs`, `tabs-test.mjs` and `tabs-shots.mjs` (sectors tabs), `panel-shots.mjs`, `object-fit-test.mjs` (grid objects inside their viewBox), and for the full product page's pinned steps `pinned-steps-test.mjs`, `panel-align-test.mjs`, `step-exit-test.mjs`. `contact-sheet.mjs` tiles a folder of images. Need `npx astro preview --port 4322`. |
| `scripts/research-figures.py` | Gives every paper in `src/data/research.json` its Chladni figure and colour field. `src/data/scholar/*.json` are the raw Google Scholar exports that file was built from. |
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. | | `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. | | `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
| `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). | | `public/fonts/` | Self-hosted Inter Tight and JetBrains Mono, variable (latin, latin-ext). |
| `public/video/` | The home hero's background loop, MP4 and WebM. Shipped as-is; Astro does not process video. |
## Routes ## Routes
@@ -40,11 +58,90 @@ npm run check # Astro + TypeScript check
## Things the host has to provide ## Things the host has to provide
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` and a honeypot - **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
field `website` to `POST /contact`, as the current site does. The handler is server-side and honeypot field `website` to `POST /contact`. That path is handled by `server/contact.mjs`
not part of this static build. (Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
- **Matomo.** The privacy page links to the existing opt-out on `cipher.uber.space`. The tracking Reply-To. The form sends in place and shows the result in a toast; a post that asks for HTML
snippet itself is not included; add it in `src/layouts/Base.astro` if analytics should continue. is redirected 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.
Against spam, every message carries the answer to a proof-of-work challenge (ALTCHA,
`altcha-lib`). The form fetches a signed challenge from `GET /contact/challenge` when the
visitor first focuses it and solves it in the background (about half a second on a laptop, one
to two on a phone). The handler refuses a post without a valid, unexpired, unused solution, or
one sent less than 3 seconds after the challenge was issued. Nothing is stored in the browser
and no third party is involved, so there is nothing to consent to. The form therefore needs
JavaScript; without it a `<noscript>` line points to the email address.
In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `GET /notify/verify` 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. |
| `NOTIFY_SECRET` | random per start | Signs alpha confirmation links (`openssl rand -hex 32`). Set it, or links sent before a restart stop working. |
| `SITE_URL` | `https://www.ciphercell.de` | Base of the confirmation links. |
| `ALTCHA_HMAC_SECRET`, `ALTCHA_HMAC_KEY_SECRET` | random per start | Signing secrets for the challenge (`openssl rand -hex 32`). Set them so a restart does not void challenges already handed out. |
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 the same paths 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`, the honeypot `website` and the proof-of-work answer `altcha` to
`POST /notify`, handled by `server/contact.mjs` like the contact form. It is double opt-in
without a database: the handler emails the address a link to `GET /notify/verify` carrying a
signed token (address, language, time of consent; valid 7 days) and keeps nothing. Opening the
link confirms: the handler sends the confirmed registration, with the consent wording and both
times, to `CONTACT_TO`, and redirects to `/notify/confirmed`. A mail scanner that opens every
link in a message will confirm it too. Old `/notify/confirm` links are redirected by nginx. That mailbox is the list. The privacy page promises deletion
after the alpha mail is sent or on withdrawal. The result pages `/notify/confirmed` and
`/notify/error` are `noindex` and left out of the sitemap. Set the flag to false to bring back
the full product page.
- **Matomo.** Production builds load the tracker from the self-hosted Matomo at
`matomo.ciphercell.de` (`src/components/Matomo.astro`, included in `src/layouts/Base.astro`);
URL and site ID are in `src/data/analytics.ts`. It runs without cookies and honours Do Not
Track, as the privacy page states; `astro dev` never loads it. The opt-out on the privacy page
(`src/components/OptOut.astro`) asks the same tracker: opting out stores the one cookie
`mtm_consent_removed`, opting back in deletes it. With Do Not Track, or when a blocker keeps
`matomo.js` from loading, it says that nothing is counted instead of showing the button.
## Insights filter
The Insights index can be filtered by type: Essays (posts), LinkedIn (mentions with
`platform: 'LinkedIn'`) and Other (mentions from any other platform). A filter only appears once it
has an entry, and the bar only when there are at least two types. The choice is kept in
`?type=essay|linkedin|other`, so a filtered view can be linked. The types are in `src/data/insights.ts`.
## Adding a mention
A mention is a short pointer to a post elsewhere (LinkedIn) that CipherCell took part in. It appears
in the Insights list and on the home page, sorted by date with the essays, and has a short page at
`/insights/<slug>`: headline, summary, a link out, and the post embedded on request. The embed is
two-click: nothing loads from LinkedIn until the visitor presses the button (see the privacy page).
The embed URL is derived from a LinkedIn post link; set `embed` by hand only if that fails, and
`embedHeight` (pixels, default 720) if the post is cut off or leaves a gap. Text below the front
matter is shown as our own notes. A slug used by both an essay and a mention fails the build. Create `src/content/mentions/<slug>.md` with front matter only:
```yaml
title: 'Component security testing at the xG-ALOE Plugfest 2026.' # our headline, sentence case
summary: 'One or two sentences in our words on what the post says.'
url: 'https://www.linkedin.com/posts/...' # paste the share link; query and fragment are stripped
source: 'xG-ALOE' # who posted it
platform: 'LinkedIn' # optional, defaults to LinkedIn
date: 2026-09-17 # the post's publication date
lang: en # language of the post itself
```
Summarise only what the post says.
## Adding a post ## Adding a post
@@ -54,55 +151,50 @@ The three newest posts appear on the home page automatically.
## The card drawings ## The card drawings
The objects on the stage cards ("What we do.") and the product cards do not animate on arrival; The objects on the product cards do not animate on arrival; they are simply drawn. Hovering a
they are simply drawn. Hovering a card sets `--object-grid`, which the drawing answers by letting card sets `--object-grid`, which the drawing answers by letting its grid recede to 26% while the
its grid recede to 26% while the lines that carry the idea stay: the cube's own edges, the tile lines that carry the idea stay: the inner cube inside its shell, the column to fix first, the
lifted out of the plane, the wave's profiles, the inner cube inside its shell, the column to fix newest layer of the stack. The card also sets `--object-scale`. Both are custom properties the card owns and the
first. The card also sets `--object-scale`. Both are custom properties the card owns and the
drawing responds to, so the interaction lives with the card and the response with the drawing. drawing responds to, so the interaction lives with the card and the response with the drawing.
It is behind `@media (hover: hover)` so touch never sticks in it, and it is the same language as It is behind `@media (hover: hover)` so touch never sticks in it, and it is the same language as
the hover focus on the product page schematics. the hover focus on the product page schematics.
## The hero video ## The hero
The home hero plays `public/video/hero-plant.mp4` / `.webm` behind the headline, over the The home hero is a noise field in the logo's blues on night, drawn by a small WebGL shader
`plant-at-dusk.jpg` still. The still is the video's own first frame, so it carries the hero until (`src/scripts/noise-field.ts`) that drifts slowly and bends towards the pointer. It renders at half
the video is ready and stays put for anyone who never gets one. resolution, caps at 30 fps and only runs while the hero is on screen. Underneath sits
`hero-field.png`, a frame of the same shader, so the hand-over is invisible and the poster carries
The video is only fetched when it will be seen: not under `prefers-reduced-motion`, not on the hero for anyone who never gets the canvas: reduced motion, no WebGL, no script.
`saveData`, and not below 48rem, where a 21:9 frame would be cropped to a sliver for the most
expensive bytes. The sources are attached by script rather than in the markup, so nothing is
requested otherwise. A blocked autoplay also just leaves the still in place.
The source clip was generated with Magnific (Kling 2.5, 10 s, from the hero still as its start
frame, https://www.magnific.com/app/creation/5jPaI3qKxe). `scripts/encode-hero-video.sh` turns a
clip into the looping file: it plays forward, then backward, so the loop point cannot be seen, and
encodes 1920-wide H.264 and VP9. A 20-second loop lands at 862 KB and 472 KB. To replace it:
```sh
FFMPEG=/path/to/ffmpeg ./scripts/encode-hero-video.sh source.mp4 public/video/hero-plant 1920
```
Keep the first frame of any replacement equal to `plant-at-dusk.jpg`, or replace both together.
## Imagery ## Imagery
The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it
remains the cover of the first post. The five other scene images were generated with Magnific remains the cover of the first post. The four other scene images were generated with Magnific
(Seedream 5 Pro) on 18 September 2026 in a desaturated documentary style, because no real (Seedream 5 Pro) on 18 September 2026 in a desaturated documentary style, because no real
photography existed: photography existed:
| File | Used in | Magnific creation | | File | Used in | Magnific creation |
|---|---|---| |---|---|---|
| `plant-at-dusk.jpg` | Hero | https://www.magnific.com/app/creation/DoQZmiipcl | | `monitoring-room-glass.jpg` | Method section | https://www.magnific.com/app/creation/bx34g4D5Y2 |
| `engineer-glass.jpg` | Method section | https://www.magnific.com/app/creation/3zdScUIREY |
| `sector-5g.jpg` | Sectors, tab 1 | https://www.magnific.com/app/creation/s7ZQP52l8e | | `sector-5g.jpg` | Sectors, tab 1 | https://www.magnific.com/app/creation/s7ZQP52l8e |
| `sector-substation.jpg` | Sectors, tab 2 | https://www.magnific.com/app/creation/yi0r27vPW9 | | `sector-substation.jpg` | Sectors, tab 2 | https://www.magnific.com/app/creation/yi0r27vPW9 |
| `sector-datacentre.jpg` | Sectors, tab 3 | https://www.magnific.com/app/creation/eIHAvetdqL | | `sector-datacentre.jpg` | Sectors, tab 5 | https://www.magnific.com/app/creation/eIHAvetdqL |
`monitoring-room-glass.jpg` was regenerated on 8 October 2026 from the same prompt without a person,
with the original engineer image as the style reference; it replaced `engineer-glass.jpg`.
`sector-ot.jpg` (Sectors, tab 3, operational technology) is a real photograph from the Freepik
stock catalogue via Magnific, "Workers in control room" (free licence, Canon EOS 5D Mark III, 2017),
downloaded on 7 October 2026 and scaled to 2304 px.
`sector-device-line.jpg` (Sectors, tab 4, products with digital elements) comes from the same
catalogue: "Shot of an Electronics Factory Workers Assembling Circuit Boards by Hand While it Stands on
the Assembly Line" (premium licence), downloaded on 8 October 2026 and scaled to 2304 px. It replaced
`sector-rd-engineer.jpg`, a CAD workstation that read as mechanical engineering rather than devices.
They are placeholders in the sense of the brief: swap them for the company's own documentary They are placeholders in the sense of the brief: swap them for the company's own documentary
photography when it exists, keeping the same file names and the `img-doc` treatment. The grid photography when it exists, keeping the same file names and the `img-doc` treatment. The grid
objects on the stage cards were prototyped as a Magnific sheet objects on the product cards were prototyped as a Magnific sheet
(https://www.magnific.com/app/creation/aFsD5crfSh) and then rebuilt as procedural SVG so the (https://www.magnific.com/app/creation/aFsD5crfSh) and then rebuilt as procedural SVG so the
stroke matches the rest of the site. stroke matches the rest of the site.
@@ -129,9 +221,12 @@ them for review.
## Interaction and motion ## Interaction and motion
Three islands of script: the mobile navigation toggle in `Header.astro`, the sectors tabs in Islands of script: the mobile navigation toggle in `Header.astro`, the hero's noise field
`Sectors.astro` (`src/scripts/tabs.ts`: ARIA tablist, arrow keys, Home and End), and the reveal (`src/scripts/noise-field.ts`), the sectors tabs in `Sectors.astro` (`src/scripts/tabs.ts`: ARIA
observer used by the product page's drawings. tablist, arrow keys, Home and End), the quote picker, the two forms (`send-form.ts`,
`altcha.ts`), the research cards' figures on the team page (`chladni.ts`), the Insights filter,
the two-click embed on mention pages, the Matomo opt-out, and on the product page the pinned
steps and the verticals tabs.
Motion on the product page, all of it on wide screens: the hero's seventeen lines draw in on load Motion on the product page, all of it on wide screens: the hero's seventeen lines draw in on load
and meet in one point (off under `prefers-reduced-motion`); hovering a node in a schematic dims and meet in one point (off under `prefers-reduced-motion`); hovering a node in a schematic dims
+3 -1
View File
@@ -5,11 +5,13 @@ import sitemap from '@astrojs/sitemap';
export default defineConfig({ export default defineConfig({
site: 'https://www.ciphercell.de', site: 'https://www.ciphercell.de',
trailingSlash: 'never', trailingSlash: 'never',
integrations: [sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })], integrations: [sitemap({ filter: (page) => !/\/(contact|notify)\//.test(page), i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })],
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
locales: ['en', 'de'], locales: ['en', 'de'],
routing: { prefixDefaultLocale: false }, routing: { prefixDefaultLocale: false },
}, },
build: { inlineStylesheets: 'auto' }, build: { inlineStylesheets: 'auto' },
// In development the form posts to the contact handler started with `npm run contact`.
vite: { server: { proxy: { '^/(contact(/challenge)?|notify(/verify)?)(\\?|$)': { 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;'
+114
View File
@@ -0,0 +1,114 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 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;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/xml application/javascript application/json image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location = /healthz {
access_log off;
default_type text/plain;
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;
}
# The proof-of-work challenge the form solves before it sends; issued by the same handler.
location = /contact/challenge {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 10s;
}
# The alpha sign-up, same handler: the form posts here.
location = /notify {
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 = @notify_down;
}
# The link in the confirmation email; opening it confirms.
location = /notify/verify {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
proxy_intercept_errors on;
error_page 502 503 504 = @notify_down;
}
# Links sent before the confirmation moved off the button page.
location ~ ^(/de)?/notify/confirm$ { return 302 /notify/verify$is_args$args; }
location @notify_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/notify/error; }
return 303 /notify/error;
}
# 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;
}
location / {
try_files $uri $uri.html $uri/index.html =404;
}
# Astro's hashed build assets never change.
location ^~ /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
location ~* \.(?:woff2|mp4|webm|avif|webp|jpg|jpeg|png|svg)$ {
add_header Cache-Control "public, max-age=2592000" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
+265 -157
View File
@@ -8,7 +8,7 @@
"name": "ciphercell-homepage", "name": "ciphercell-homepage",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0", "altcha-lib": "^2.6.0",
"astro": "^7.3.3", "astro": "^7.3.3",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
@@ -1140,15 +1140,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fontsource-variable/inter-tight": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter-tight/-/inter-tight-5.3.0.tgz",
"integrity": "sha512-ZhZZ29sNZ15P1K+cUt13GeBRYON0akgmNiftshUeDLos7X7zGcRgPzt434879Xhq1QbwlW3M/apBMk7jWabwvA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@img/colour": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1159,9 +1150,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1177,13 +1168,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3" "@img/sharp-libvips-darwin-arm64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1199,20 +1190,20 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3" "@img/sharp-libvips-darwin-x64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-freebsd-wasm32": { "node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
], ],
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.4" "@img/sharp-wasm32": "0.35.5"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -1222,9 +1213,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1238,9 +1229,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1254,12 +1245,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1270,12 +1264,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1286,12 +1283,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1302,12 +1302,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1318,12 +1321,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1334,12 +1340,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1350,12 +1359,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1366,12 +1378,15 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1382,12 +1397,15 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1400,16 +1418,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3" "@img/sharp-libvips-linux-arm": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1422,16 +1443,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3" "@img/sharp-libvips-linux-arm64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1444,16 +1468,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3" "@img/sharp-libvips-linux-ppc64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1466,16 +1493,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3" "@img/sharp-libvips-linux-riscv64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1488,16 +1518,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3" "@img/sharp-libvips-linux-s390x": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1510,16 +1543,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3" "@img/sharp-libvips-linux-x64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1532,16 +1568,19 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3" "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1554,13 +1593,13 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3" "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1574,16 +1613,16 @@
} }
}, },
"node_modules/@img/sharp-webcontainers-wasm32": { "node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.4" "@img/sharp-wasm32": "0.35.5"
}, },
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
@@ -1593,9 +1632,9 @@
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1612,9 +1651,9 @@
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1631,9 +1670,9 @@
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2268,6 +2307,75 @@
"ajv": "^8.0.0-beta.0" "ajv": "^8.0.0-beta.0"
} }
}, },
"node_modules/altcha-lib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
"license": "MIT",
"bin": {
"altcha-lib": "bin/cli.mjs"
},
"peerDependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.0 || ^9.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@sveltejs/kit": "^2.0.0",
"cookie-parser": "^1.0.0",
"cors": "^2.0.0",
"express": "^4.0.0 || ^5.0.0",
"fastify": "^5.0.0",
"h3": "^2.0.0",
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"@fastify/cookie": {
"optional": true
},
"@fastify/cors": {
"optional": true
},
"@fastify/formbody": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
"@nestjs/core": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
},
"@nestjs/platform-fastify": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"cookie-parser": {
"optional": true
},
"cors": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
},
"h3": {
"optional": true
},
"hono": {
"optional": true
}
}
},
"node_modules/am-i-vibing": { "node_modules/am-i-vibing": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
@@ -2725,9 +2833,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.9.2", "version": "5.9.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
"integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/devlop": { "node_modules/devlop": {
@@ -3083,23 +3191,6 @@
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/h3": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
"license": "MIT",
"dependencies": {
"cookie-es": "^1.2.3",
"crossws": "^0.3.5",
"defu": "^6.1.6",
"destr": "^2.0.5",
"iron-webcrypto": "^1.2.1",
"node-mock-http": "^1.0.4",
"radix3": "^1.1.2",
"ufo": "^1.6.3",
"uncrypto": "^0.1.3"
}
},
"node_modules/hast-util-to-html": { "node_modules/hast-util-to-html": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -3153,9 +3244,9 @@
} }
}, },
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/iron-webcrypto": { "node_modules/iron-webcrypto": {
@@ -4112,9 +4203,9 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.35.4", "version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
@@ -4128,31 +4219,31 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.4", "@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.4", "@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.4" "@img/sharp-win32-x64": "0.35.5"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/node": { "@types/node": {
@@ -4206,9 +4297,9 @@
} }
}, },
"node_modules/smol-toml": { "node_modules/smol-toml": {
"version": "1.8.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
"integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">= 18" "node": ">= 18"
@@ -4218,9 +4309,9 @@
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4634,6 +4725,23 @@
} }
} }
}, },
"node_modules/unstorage/node_modules/h3": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
"license": "MIT",
"dependencies": {
"cookie-es": "^1.2.3",
"crossws": "^0.3.5",
"defu": "^6.1.6",
"destr": "^2.0.5",
"iron-webcrypto": "^1.2.1",
"node-mock-http": "^1.0.4",
"radix3": "^1.1.2",
"ufo": "^1.6.3",
"uncrypto": "^0.1.3"
}
},
"node_modules/verkit": { "node_modules/verkit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.0.tgz",
+6 -5
View File
@@ -5,13 +5,14 @@
"private": true, "private": true,
"types": ".design-sync/ds-types/index.d.ts", "types": ".design-sync/ds-types/index.d.ts",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
"build": "astro build", "build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
"preview": "astro preview", "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
"check": "astro check" "check": "ASTRO_TELEMETRY_DISABLED=1 astro check",
"contact": "node --env-file-if-exists=.env server/contact.mjs"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0", "altcha-lib": "^2.6.0",
"astro": "^7.3.3", "astro": "^7.3.3",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#E9E5DF"/><text x="16" y="22" text-anchor="middle" font-family="Inter Tight, Inter, system-ui, sans-serif" font-weight="600" font-size="19" letter-spacing="-1" fill="#1A1A18">CC</text></svg>

Before

Width:  |  Height:  |  Size: 286 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.
Binary file not shown.
-21
View File
@@ -1,21 +0,0 @@
import { chromium } from 'playwright';
/* Jumps to card 3 via its dot, then back to card 1 via its dot, and reports where the page rests. */
const [,, url, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
const dots = page.locator('[data-deck-dots] a');
await page.evaluate((y) => window.scrollTo(0, y), snaps[2]);
await page.waitForTimeout(800);
const before = await page.evaluate(() => Math.round(window.scrollY));
await dots.nth(0).click();
await page.waitForTimeout(1500);
const back = await page.evaluate(() => Math.round(window.scrollY));
await dots.nth(3).click();
await page.waitForTimeout(1500);
const fwd = await page.evaluate(() => Math.round(window.scrollY));
const active = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-dots] a')).map((a) => a.getAttribute('aria-current') === 'true'));
const hint = await page.evaluate(() => { const h = document.querySelector('[data-deck-hint]'); return { href: h?.getAttribute('href'), last: h?.hasAttribute('data-last') }; });
console.log({ snaps, before, backTo1: back, expected1: snaps[0], fwdTo4: fwd, expected4: snaps[3], active, hint });
await browser.close();
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Turns a generated clip into a seamless looping hero background:
# the clip plays forward, then backward, so the loop point cannot be seen.
# Usage: encode-hero-video.sh <source.mp4> <out-basename> [width]
set -euo pipefail
FFMPEG="${FFMPEG:-ffmpeg}"
SRC="$1"; OUT="$2"; W="${3:-1920}"
FILTER="[0:v]scale=${W}:-2:flags=lanczos,fps=25,setpts=PTS-STARTPTS,split[a][b];[b]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[r];[a][r]concat=n=2:v=1[v]"
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
-c:v libx264 -profile:v high -crf 30 -preset slow -pix_fmt yuv420p -movflags +faststart "${OUT}.mp4"
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
-c:v libvpx-vp9 -crf 40 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 "${OUT}.webm"
ls -lh "${OUT}.mp4" "${OUT}.webm"
-39
View File
@@ -1,39 +0,0 @@
import { chromium } from 'playwright';
/* Checks the hero video: does it load and play, is it gated off on phones and under reduced
motion, and does the hero fill the viewport. */
const [,, url, prefix] = process.argv;
const browser = await chromium.launch();
async function probe(label, opts, shots = []) {
const ctx = await browser.newContext(opts);
const page = await ctx.newPage();
const requested = [];
page.on('request', (r) => { if (/\.(mp4|webm)$/.test(r.url())) requested.push(r.url().split('/').pop()); });
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(2500);
const info = await page.evaluate(() => {
const v = document.querySelector('video');
const hero = document.querySelector('.hero');
return {
hasVideo: !!v,
ready: v?.hasAttribute('data-ready') ?? null,
paused: v?.paused ?? null,
currentTime: v ? Math.round(v.currentTime * 10) / 10 : null,
videoSize: v ? `${v.videoWidth}x${v.videoHeight}` : null,
heroHeight: Math.round(hero.getBoundingClientRect().height),
viewport: window.innerHeight,
headerH: Math.round(document.querySelector('header').getBoundingClientRect().height),
};
});
for (const t of shots) {
await page.waitForTimeout(t);
await page.screenshot({ path: `${prefix}-${label}-${t}.png` });
}
console.log(label, JSON.stringify({ ...info, requested }));
await ctx.close();
}
await probe('desktop', { viewport: { width: 1440, height: 900 } }, [0, 2500]);
await probe('mobile', { viewport: { width: 390, height: 844 } });
await probe('reduced', { viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
await browser.close();
-20
View File
@@ -1,20 +0,0 @@
import { chromium } from 'playwright';
/* Reports where the chevron hint sits at the first snap and whether anything covers it. */
const [,, url, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
await page.evaluate((y) => window.scrollTo(0, y), snaps[0]);
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const h = document.querySelector('[data-deck-hint]');
const r = h.getBoundingClientRect();
const cs = getComputedStyle(h);
const svg = h.querySelector('svg').getBoundingClientRect();
const atPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 10);
const next = document.querySelectorAll('[data-deck-card]')[1].getBoundingClientRect();
return { hint: { top: r.top, bottom: r.bottom, h: r.height }, svg: { top: svg.top, bottom: svg.bottom }, position: cs.position, display: cs.display, bottom: cs.bottom, zIndex: cs.zIndex, opacity: cs.opacity, atPoint: atPoint && (atPoint.tagName + '.' + atPoint.className), nextCardTop: next.top, innerHeight: window.innerHeight };
});
console.log(JSON.stringify(info, null, 1));
await browser.close();
-14
View File
@@ -1,14 +0,0 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
/* Phone: the sweep should still run, the hover state should not exist. */
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const page = await ctx.newPage();
await page.goto(process.argv[2], { waitUntil: 'networkidle' });
await page.locator('.stages__list').scrollIntoViewIfNeeded();
await page.waitForTimeout(2000);
console.log('mobile shown:', JSON.stringify(await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-reveal]')).map((o) => o.hasAttribute('data-shown')))));
console.log('grid opacity (no hover):', await page.evaluate(() =>
getComputedStyle(document.querySelector('.grid-object__base')).opacity));
await page.screenshot({ path: process.argv[3] });
await browser.close();
+158
View File
@@ -0,0 +1,158 @@
"""Give every paper in src/data/research.json its own Chladni figure and colour field.
Each paper gets `figure` (parameters for src/scripts/chladni.ts) and `field` (three colour tokens
and their positions), seeded from the paper id so a re-run gives the same result and a new paper
gets its own. Generation follows the rules learned while hand-tuning the first figures:
- plate windows stay clear of both axes and of any coordinate where n·t and m·t are both whole
(the field is zero along a straight line there), plates are never mirrored (the seams read as
straight lines), and the
weights avoid a = ±b (which zeroes the diagonals u = ±v);
- hover keeps both plate terms in play (a single sin·sin term is a straight grid);
- source figures use three to six sources (two sources draw a straight bisector), spread over
enough of a wavelength that they interfere (radius × frequency >= 0.6 for five or six sources,
0.35 for three or four); packed tighter they collapse into plain concentric rings;
- one colour patch sits in the middle of the card, so no field is mostly black;
- line density, the share of grid cells a nodal line passes through, stays within the range of
the hand-picked figures, so no card is near-empty or a scribble.
A paper that already has a figure keeps it unless --regenerate is passed, so hand edits survive.
Usage: python3 scripts/research-figures.py [--regenerate] [--report]
"""
import hashlib, json, math, random, sys
PATH = 'src/data/research.json'
ASPECT = 3 # the card canvas is 3:1
Q = 61 # grid for the density check (the site renders at 121)
COLOURS = [
'logo-deep', 'logo-mid', 'logo-teal', 'logo-cyan',
'field-indigo', 'field-petrol', 'field-sea', 'field-steel', 'field-dusk',
'field-cobalt', 'field-ocean', 'field-glacier', 'field-aqua', 'field-jade', 'field-slate', 'field-plum',
]
# Light tones carry the glow; at most one per field, so every card keeps a dark side.
LIGHT = {'logo-cyan', 'field-glacier', 'field-aqua', 'logo-teal'}
def field_fn(f, shape=None):
s = {**f, **(shape or {})}
if f['mode'] == 'plate':
a, b, n, m = s['a'], s['b'], s['n'], s['m']
rot = s.get('rotation', 0)
c, si = math.cos(rot), math.sin(rot)
ox, oy = f['origin']
def z(x, y):
dx, dy = x - ox, y - oy
u, v = ox + dx * c - dy * si, oy + dx * si + dy * c
return a * math.sin(math.pi * n * u) * math.sin(math.pi * m * v) + b * math.sin(math.pi * m * u) * math.sin(math.pi * n * v)
return z
pts = []
for i in range(f['sources']):
t = i / f['sources'] * 2 * math.pi - math.pi / 2 + s.get('spin', 0)
pts.append((f['origin'][0] + s['radius'] * math.cos(t), f['origin'][1] + s['radius'] * math.sin(t)))
k = 2 * math.pi * s['frequency']
inv = 1 / s['elasticity']
return lambda x, y: sum(math.sin(k * (s['phase'] - inv * math.hypot(x - px, y - py))) for px, py in pts)
def density(f, shape=None):
z = field_fn(f, shape)
h = f['length']; w = h * ASPECT
x0, y0 = f['origin'][0] - w / 2, f['origin'][1] - h / 2
g = [[z(x0 + c * w / (Q - 1), y0 + r * h / (Q - 1)) > 0 for c in range(Q)] for r in range(Q)]
cells = sum(1 for r in range(Q - 1) for c in range(Q - 1) if len({g[r][c], g[r][c + 1], g[r + 1][c], g[r + 1][c + 1]}) > 1)
return cells / (Q - 1) ** 2
def straight_lines(n, m, origin, hx, hy):
"""True if the window crosses a coordinate where n·t and m·t are both whole: both terms of the
plate vanish there for every value of the other coordinate, which draws a straight line."""
for centre, half in ((origin[0], hx), (origin[1], hy)):
steps = 400
for i in range(steps + 1):
t = centre - half + 2 * half * i / steps
if abs(n * t - round(n * t)) < 0.03 and abs(m * t - round(m * t)) < 0.03:
return True
return False
def rnd(rng, lo, hi, step=0.01):
return round(rng.uniform(lo, hi) / step) * step
def make_plate(rng, rotated):
while True:
n, m = rnd(rng, 1.1, 4.6), rnd(rng, 1.1, 4.6)
if abs(n - m) < 0.35: continue
length = rnd(rng, 1.0, 2.8)
if not (2.4 <= max(n, m) * length <= 8.5): continue
a = rnd(rng, 0.8, 1.7) * rng.choice([-1, 1])
b = rnd(rng, 0.4, 1.5) * rng.choice([-1, 1])
if abs(abs(a) - abs(b)) < 0.25: continue
rotation = rnd(rng, 0.2, 1.3) if rotated else 0
# Keep the whole (rotated) window off both axes.
half_diag = math.hypot(length * ASPECT / 2, length / 2) if rotated else None
hx = half_diag if rotated else length * ASPECT / 2
hy = half_diag if rotated else length / 2
origin = [-round(hx + rnd(rng, 0.3, 2.5), 2), round(hy + rnd(rng, 0.3, 2.5), 2)]
f = {'mode': 'plate', 'a': a, 'b': b, 'n': n, 'm': m, 'origin': origin, 'length': length}
if rotated: f['rotation'] = rotation
if straight_lines(n, m, origin, hx, hy): continue
# Hover: flip the dominant weight and bring it down to 40-75 % of the other. Never near zero
# (a single sin·sin term draws a grid of straight lines) and never near a = ±b.
ha = None
for _ in range(20):
cand = round(-math.copysign(1, a) * abs(b) * rng.uniform(0.4, 0.75), 2)
if abs(abs(cand) - abs(b)) >= 0.2:
ha = cand; break
if ha is None: continue
f['hover'] = {'a': ha}
return f
def make_sources(rng):
while True:
count = rng.choice([3, 3, 4, 4, 5, 6])
length = rnd(rng, 1.8, 2.8)
f = {
'mode': 'sources', 'sources': count,
'frequency': rnd(rng, 1.0, 2.5), 'phase': rnd(rng, 0, 0.5), 'elasticity': 1,
'radius': rnd(rng, 0.3, min(1.1, length / 2.2)),
'origin': [rnd(rng, -1.3, 1.3), rnd(rng, -0.4, 0.4)], 'length': length,
'spin': rnd(rng, 0, 2 * math.pi / count),
}
if f['radius'] * f['frequency'] < (0.6 if count >= 5 else 0.35): continue
f['hover'] = {'phase': round(f['phase'] + rnd(rng, 0.35, 0.55), 2), 'spin': round(f['spin'] + rnd(rng, 0.35, 0.65), 2)}
return f
def make_field(rng):
while True:
cols = rng.sample(COLOURS, 3)
if sum(c in LIGHT for c in cols) <= 1: break
pos = [[rnd(rng, 30, 70, 1), rnd(rng, 30, 70, 1)]] + [[rnd(rng, 5, 95, 1), rnd(rng, 5, 95, 1)] for _ in range(2)]
return {'colors': cols, 'positions': pos}
def generate(pid, lo, hi):
rng = random.Random(int(hashlib.sha256(pid.encode()).hexdigest(), 16))
for _ in range(200):
kind = rng.choices(['sources', 'plate', 'rotated'], [0.45, 0.35, 0.2])[0]
f = make_sources(rng) if kind == 'sources' else make_plate(rng, kind == 'rotated')
d0, d1 = density(f), density(f, f['hover'])
if lo <= d0 <= hi and lo * 0.8 <= d1 <= hi * 1.2:
return f, make_field(rng), d0
raise SystemExit('no acceptable figure for ' + pid)
if __name__ == '__main__':
data = json.load(open(PATH))
# Density band, calibrated on the 18 hand-picked figures (0.24 to 0.51 on this grid), without
# the two busiest, which read as scribbles at small card sizes.
lo, hi = 0.23, 0.45
regen = '--regenerate' in sys.argv
for p in data['papers']:
if 'figure' in p and not regen: continue
p['figure'], p['field'], d = generate(p['id'], lo, hi)
if '--report' in sys.argv: print(f"{d:.3f} {p['figure']['mode']:8} {p['id'][:50]}")
json.dump(data, open(PATH, 'w'), indent=2, ensure_ascii=False)
-28
View File
@@ -1,28 +0,0 @@
import { chromium } from 'playwright';
/* Scrolls to positions around each deck card's snap point and reports where the page comes to rest. */
const [,, url, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const info = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('[data-deck-snap]'));
const cs = getComputedStyle(document.documentElement);
return cards.map((c, i) => {
const st = getComputedStyle(c);
return { i, naturalTop: c.offsetTop + (c.offsetParent?.getBoundingClientRect().top ?? 0) + window.scrollY, marginTop: st.scrollMarginTop, snapAlign: st.scrollSnapAlign, snapStop: st.scrollSnapStop, htmlSnap: cs.scrollSnapType };
});
});
console.log(JSON.stringify(info, null, 1));
// expected snap points: naturalTop - scrollMarginTop
const results = [];
for (const c of info) {
const expected = Math.round(c.naturalTop - parseFloat(c.marginTop));
for (const off of [-220, 180, 320, 352]) {
await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'auto' }), expected + off);
await page.waitForTimeout(1200);
const y = await page.evaluate(() => Math.round(window.scrollY));
results.push({ card: c.i, expected, offset: off, landed: y, snapped: Math.abs(y - expected) < 2 });
}
}
console.table(results);
await browser.close();
-13
View File
@@ -1,13 +0,0 @@
import { chromium } from 'playwright';
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 3000 : 1800);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
-13
View File
@@ -1,13 +0,0 @@
import { chromium } from 'playwright';
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 2500 : 1500);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+363
View File
@@ -0,0 +1,363 @@
/**
* The handler for the site's two forms; nginx proxies their paths here and everything else stays
* static. Both send by SMTP.
*
* Contact (POST /contact). The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the
* answer to a proof-of-work challenge from GET /contact/challenge (ALTCHA, solved in the browser
* while the visitor types). The browser asks for JSON and shows the result in place; a post that
* asks for HTML is answered with a redirect to /contact/sent or /contact/error (under /de for German).
* A post without a solved challenge is refused, so the form needs scripting; the address next
* to it does not.
*
* Alpha sign-up (POST /notify, GET /notify/verify). Double opt-in without a database: the form
* posts email, consent=yes, list, lang, website and altcha; the handler emails the address a link
* to /notify/verify carrying a signed token (address, language, time of consent, expiry). Opening
* the link confirms: the handler checks the signature, sends the confirmed registration to
* CONTACT_TO and redirects to /notify/confirmed. The office mailbox is the list.
*
* Configuration comes from the environment only, see README.md.
*/
import { createServer } from 'node:http';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import nodemailer from 'nodemailer';
import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
import { confirmMail } from './mail/confirm.mjs';
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);
// Confirmation links point here, never at the Host header a request happened to carry.
const SITE = (env.SITE_URL || 'https://www.ciphercell.de').replace(/\/$/, '');
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.
}
/* Challenge secrets. Without them set, each start makes its own; challenges then only outlive a
restart if the visitor fetched a new one, which the form does after every send. */
const ALTCHA_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex');
const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex');
/* Signs alpha confirmation links. Without it set, links sent before a restart stop working. */
const NOTIFY_SECRET = env.NOTIFY_SECRET || randomBytes(32).toString('hex');
if (!env.NOTIFY_SECRET) console.warn('contact: NOTIFY_SECRET not set; confirmation links will not survive a restart.');
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 };
/* Proof of work. About half a second of a laptop's time and one to two of a phone's, spent while
the visitor is still typing; a script pays it for every message. The challenge is signed, so
the server keeps nothing until a solution comes back. */
const POW = { algorithm: 'PBKDF2/SHA-256', cost: 1000, counterMin: 2000, counterMax: 5000 };
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
// Nobody writes a message in three seconds from first touching the form; a script does.
const MIN_FILL_MS = 3000;
// Solved challenges, kept until they expire, so one solution sends one message.
const spent = new Map();
setInterval(() => {
const now = Date.now();
for (const [sig, expires] of spent) if (expires < now) spent.delete(sig);
}, 60 * 1000).unref();
function issueChallenge() {
const now = Date.now();
return createChallenge({
algorithm: POW.algorithm,
cost: POW.cost,
counter: randomInt(POW.counterMin, POW.counterMax),
deriveKey,
data: { issued: now },
expiresAt: new Date(now + CHALLENGE_TTL_MS),
hmacSignatureSecret: ALTCHA_SECRET,
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
});
}
/** The posted altcha field: base64 JSON of { challenge, solution }. */
async function solved(field, now = Date.now()) {
let payload;
try {
payload = JSON.parse(Buffer.from(field || '', 'base64').toString('utf8'));
} catch {
return false;
}
const { challenge, solution } = payload || {};
if (!challenge?.signature || spent.has(challenge.signature)) return false;
let result;
try {
result = await verifySolution({
challenge,
solution,
deriveKey,
hmacSignatureSecret: ALTCHA_SECRET,
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
});
} catch {
return false;
}
if (!result.verified) return false;
// Signed with the challenge, so it is the time the server issued it.
const issued = Number(challenge.parameters.data?.issued);
if (!(now - issued >= MIN_FILL_MS)) return false;
spent.set(challenge.signature, challenge.parameters.expiresAt * 1000);
return true;
}
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);
});
}
/* Alpha sign-up. */
const CONFIRM_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const NOTIFY_LIST = 'cipherverifier-alpha';
// The wording the visitor agreed to, recorded with the registration. Same text as
// soon.notify.consent in src/i18n/ui.ts; change both together.
const CONSENT = {
en: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
de: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
};
// Confirmed tokens, until they expire, so a second click does not file the address twice.
const confirmed = new Map();
setInterval(() => {
const now = Date.now();
for (const [mac, expires] of confirmed) if (expires < now) confirmed.delete(mac);
}, 60 * 60 * 1000).unref();
const mac = (body) => createHmac('sha256', NOTIFY_SECRET).update(body).digest();
function sign(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${body}.${mac(body).toString('base64url')}`;
}
/** The payload of a token this handler signed, or null. */
function unsign(token) {
const [body, sig] = String(token || '').split('.');
if (!body || !sig) return null;
const expected = mac(body);
const given = Buffer.from(sig, 'base64url');
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
try {
return { sig, ...JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) };
} catch {
return null;
}
}
/* Answers. A browser with scripting asks for JSON; a plain post is redirected to a page. */
const PAGES = {
contact: { sent: '/contact/sent', error: '/contact/error' },
notify: { sent: '/cipherverifier', error: '/notify/error' },
};
function reply(req, res, lang, outcome, pages = PAGES.contact) {
const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, 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;
}
redirect(res, lang, outcome === 'sent' ? pages.sent : pages.error);
}
function redirect(res, lang, path) {
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}${path}`, 'Cache-Control': 'no-store' });
res.end();
}
async function contact(req, res, form, lang) {
const data = validate(form);
if (!data) { reply(req, res, lang, 'invalid'); return; }
// No solved challenge: a script, or a browser without scripting. Checked before the rate
// limits, so unsolved posts cannot use up the budget meant for visitors.
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; }
if (limited(clientIp(req))) { 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');
}
}
/** Step one of the sign-up: a confirmation link to the address given. Nothing is kept. */
async function notify(req, res, form, lang) {
const email = oneLine(form.get('email') || '');
const valid = email.length <= MAX.email && EMAIL.test(email)
&& form.get('consent') === 'yes' && form.get('list') === NOTIFY_LIST;
if (!valid) { reply(req, res, lang, 'invalid', PAGES.notify); return; }
// The proof of work matters most here: this path sends mail to an address a stranger typed.
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified', PAGES.notify); return; }
if (limited(clientIp(req))) { reply(req, res, lang, 'limited', PAGES.notify); return; }
const now = Date.now();
const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS });
const link = `${SITE}/notify/verify?t=${token}`;
try {
await transport.sendMail({
from: { name: 'CipherCell', address: FROM },
to: email,
...confirmMail({ lang, email, link, site: SITE }),
});
reply(req, res, lang, 'sent', PAGES.notify);
} catch (err) {
console.error('notify: sending the confirmation failed:', err.message);
reply(req, res, lang, 'failed', PAGES.notify);
}
}
/** Step two: the link is opened; the confirmed registration goes to the office. */
async function verify(res, query) {
const t = unsign(query.get('t'));
const lang = t?.lang === 'de' ? 'de' : 'en';
const now = Date.now();
if (!t || typeof t.email !== 'string' || !(t.expires > now) || !(t.consented > 0)) {
redirect(res, lang, '/notify/error');
return;
}
if (confirmed.has(t.sig)) { redirect(res, lang, '/notify/confirmed'); return; }
try {
await transport.sendMail({
from: { name: 'CipherCell website', address: FROM },
to: TO,
replyTo: t.email,
subject: `Alpha registration: ${t.email}`,
text: [
`Email: ${t.email}`,
`List: ${NOTIFY_LIST}`,
`Language: ${lang}`,
`Consent given: ${new Date(t.consented).toISOString()}`,
`Confirmed: ${new Date(now).toISOString()}`,
'',
'Consent wording:',
CONSENT[lang],
'',
'-- ',
'Confirmed by double opt-in on ciphercell.de. Per the privacy policy, delete this address',
'once the alpha notification has been sent, or when the person withdraws consent.',
].join('\n'),
});
confirmed.set(t.sig, t.expires);
redirect(res, lang, '/notify/confirmed');
} catch (err) {
console.error('notify: filing the registration failed:', err.message);
redirect(res, lang, '/notify/error');
}
}
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const clientIp = (req) => String(req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown');
const ROUTES = { '/contact': contact, '/notify': notify };
const server = createServer(async (req, res) => {
const [path, search = ''] = (req.url || '').split('?');
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (path === '/contact/challenge' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await issueChallenge()));
return;
}
if (path === '/notify/verify' && req.method === 'GET') {
await verify(res, new URLSearchParams(search));
return;
}
const route = ROUTES[path];
if (req.method !== 'POST' || !route) { 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', path === '/contact' ? PAGES.contact : PAGES.notify); return; }
await route(req, res, form, lang);
});
server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));
+107
View File
@@ -0,0 +1,107 @@
/**
* The alpha sign-up confirmation email, in the site's voice and look: warm paper, warm black,
* one neo-grotesque, hairlines, one square button. Email clients ignore most CSS, so the layout
* is tables with inline styles, the type falls back from Inter Tight to Helvetica and Arial, and
* the logo travels as an inline attachment (cid) rather than a remote image.
*
* Colours are the tokens from src/styles/tokens.css, written out: email has no custom properties.
* The hairline is --line (ink at 14 %) flattened onto paper.
*/
import { fileURLToPath } from 'node:url';
const C = {
paper: '#E9E5DF',
ink: '#1A1A18',
muted: '#57564F',
line: '#CCC9C3',
};
const FONT = "'Inter Tight', Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif";
const LOGO_CID = 'logo@ciphercell.de';
const LOGO = { path: fileURLToPath(new URL('./logo.png', import.meta.url)), width: 140, height: 32 };
const COPY = {
en: {
subject: 'Confirm your CipherVerifier alpha registration',
preheader: 'One click confirms your registration for the CipherVerifier alpha.',
title: 'Confirm your registration.',
body: (email) => `You registered ${email} for the CipherVerifier alpha. Confirm the address, and we will email you once, when the alpha is released. Nothing else.`,
button: 'Confirm registration',
expiry: 'The link is valid for 7 days. If you did not register, ignore this email. Your address is not stored.',
fallback: 'If the button does not work, open this address in your browser:',
},
de: {
subject: 'Bitte bestätigen Sie Ihre Registrierung für die CipherVerifier-Alpha',
preheader: 'Ein Klick bestätigt Ihre Registrierung für die Alpha von CipherVerifier.',
title: 'Registrierung bestätigen.',
body: (email) => `Sie haben ${email} für die Alpha von CipherVerifier registriert. Bestätigen Sie die Adresse, und wir schreiben Ihnen einmal, sobald die Alpha erscheint. Sonst nichts.`,
button: 'Registrierung bestätigen',
expiry: 'Der Link ist 7 Tage gültig. Wenn Sie sich nicht registriert haben, ignorieren Sie diese E-Mail. Ihre Adresse wird nicht gespeichert.',
fallback: 'Falls der Button nicht funktioniert, öffnen Sie diese Adresse im Browser:',
},
};
const ADDRESS = ['CipherCell', 'Innstr. 43, 94032 Passau'];
const SITE_LABEL = 'www.ciphercell.de';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
/** { subject, text, html, attachments } for nodemailer. */
export function confirmMail({ lang, email, link, site }) {
const t = COPY[lang] ?? COPY.en;
const text = [
t.title,
'',
t.body(email),
'',
`${t.button}:`,
link,
'',
t.expiry,
'',
'-- ',
...ADDRESS,
SITE_LABEL,
].join('\n');
const p = (content, { size = 16, color = C.ink, lineHeight = 1.55, extra = '' } = {}) =>
`<p style="margin:0;font-family:${FONT};font-size:${size}px;line-height:${lineHeight};color:${color};${extra}">${content}</p>`;
const row = (content, padding) => `<tr><td style="padding:${padding};">${content}</td></tr>`;
const rule = `<tr><td style="padding:0;border-top:1px solid ${C.line};font-size:0;line-height:0;">&nbsp;</td></tr>`;
const html = `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>${esc(t.subject)}</title>
</head>
<body style="margin:0;padding:0;background:${C.paper};-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:${C.paper};">${esc(t.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${C.paper};">
<tr><td align="left" style="padding:40px 24px 48px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
${row(`<a href="${esc(site)}" style="text-decoration:none;"><img src="cid:${LOGO_CID}" width="${LOGO.width}" height="${LOGO.height}" alt="CipherCell" style="display:block;border:0;width:${LOGO.width}px;height:${LOGO.height}px;font-family:${FONT};font-size:18px;font-weight:600;color:${C.ink};"></a>`, '0 0 20px')}
${rule}
${row(p(esc(t.title), { size: 34, lineHeight: 1.08, extra: 'font-weight:500;letter-spacing:-0.02em;' }), '48px 0 20px')}
${row(p(esc(t.body(email))), '0 0 32px')}
${row(`<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td bgcolor="${C.ink}" style="background:${C.ink};">
<a href="${esc(link)}" style="display:inline-block;padding:15px 26px;font-family:${FONT};font-size:16px;line-height:1;font-weight:500;color:${C.paper};text-decoration:none;">${esc(t.button)}</a>
</td></tr></table>`, '0 0 32px')}
${row(p(esc(t.expiry), { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 16px')}
${row(p(`${esc(t.fallback)}<br><a href="${esc(link)}" style="color:${C.muted};text-decoration:underline;word-break:break-all;">${esc(link)}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 48px')}
${rule}
${row(p(`${ADDRESS.map(esc).join('<br>')}<br><a href="${esc(site)}" style="color:${C.muted};text-decoration:underline;">${SITE_LABEL}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '20px 0 0')}
</table>
</td></tr>
</table>
</body>
</html>`;
return {
subject: t.subject,
text,
html,
attachments: [{ filename: 'ciphercell.png', path: LOGO.path, cid: LOGO_CID }],
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+95
View File
@@ -0,0 +1,95 @@
{
"name": "ciphercell-contact",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ciphercell-contact",
"dependencies": {
"altcha-lib": "^2.6.0",
"nodemailer": "^7.0.13"
},
"engines": {
"node": ">=20"
}
},
"node_modules/altcha-lib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
"license": "MIT",
"bin": {
"altcha-lib": "bin/cli.mjs"
},
"peerDependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.0 || ^9.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@sveltejs/kit": "^2.0.0",
"cookie-parser": "^1.0.0",
"cors": "^2.0.0",
"express": "^4.0.0 || ^5.0.0",
"fastify": "^5.0.0",
"h3": "^2.0.0",
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"@fastify/cookie": {
"optional": true
},
"@fastify/cors": {
"optional": true
},
"@fastify/formbody": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
"@nestjs/core": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
},
"@nestjs/platform-fastify": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"cookie-parser": {
"optional": true
},
"cors": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
},
"h3": {
"optional": true
},
"hono": {
"optional": true
}
}
},
"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"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"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": {
"altcha-lib": "^2.6.0",
"nodemailer": "^7.0.13"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+62
View File
@@ -0,0 +1,62 @@
---
/* An abstract colour field with film grain: the picture for an Insights card that has none.
Palette and blob positions follow from `seed` (the entry's slug), so a card keeps its field
wherever it appears and however the list is sorted or filtered. Slot content sits on the
lower edge in paper-coloured type. */
interface Props { seed: string; class?: string }
const { seed, class: className } = Astro.props;
/* A base and three blobs per palette, chosen so neighbouring hues sit well together. */
const palettes = [
['field-plum', 'field-ember', 'field-peach', 'field-rose'],
['field-cobalt', 'field-sky', 'field-lilac', 'field-peach'],
['field-jade', 'field-mint', 'field-sun', 'field-sky'],
['field-violet', 'field-rose', 'field-lilac', 'field-sun'],
['field-ocean', 'logo-cyan', 'field-mint', 'field-ember'],
['field-ember', 'field-sun', 'field-rose', 'field-peach'],
];
/* FNV-1a, then a small LCG for the positions: stable across builds and machines. */
let h = 2166136261;
for (const ch of seed) h = Math.imul(h ^ ch.codePointAt(0)!, 16777619) >>> 0;
const next = () => (h = (Math.imul(h, 1664525) + 1013904223) >>> 0) / 2 ** 32;
const [base, ...blobs] = palettes[h % palettes.length];
const at = (lo: number, hi: number) => Math.round(lo + next() * (hi - lo));
/* Blobs stay mostly in the upper two thirds, so the type at the bottom keeps a calmer ground. */
const style = [`--base: var(--${base})`]
.concat(blobs.map((c, k) => `--c${k + 1}: var(--${c}); --x${k + 1}: ${at(0, 100)}%; --y${k + 1}: ${at(0, 70)}%`))
.join('; ');
---
<div class:list={['field', className]} style={style}>
<slot />
</div>
<style>
.field {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-5);
background-color: var(--base);
background-image:
var(--field-scrim),
radial-gradient(70% 90% at var(--x1) var(--y1), var(--c1), transparent 70%),
radial-gradient(60% 80% at var(--x2) var(--y2), var(--c2), transparent 68%),
radial-gradient(85% 110% at var(--x3) var(--y3), var(--c3), transparent 72%);
color: var(--paper);
}
/* Film grain, coarser and stronger than on the research cards: the texture is the point. */
.field::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
opacity: 0.75;
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@ const funding = lang === 'de' ? fundingDe : fundingEn;
const columns = [ const columns = [
{ {
title: ui.footer.capabilities, title: ui.footer.capabilities,
links: ui.stages.items.map((item, i) => ({ href: `${home}#capability-${i + 1}`, label: item.title })), links: ui.method.stages.map((s) => ({ href: `${home}#capabilities`, label: s.title })),
}, },
{ {
title: ui.footer.company, title: ui.footer.company,
+214 -48
View File
@@ -2,14 +2,12 @@
import Wordmark from './Wordmark.astro'; import Wordmark from './Wordmark.astro';
import { localePath, t, type Lang } from '../i18n/ui'; import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang; tone?: 'paper' | 'night' }
const { lang } = Astro.props; const { lang, tone = 'paper' } = Astro.props;
const ui = t(lang); const ui = t(lang);
const home = localePath(lang, '/'); const home = localePath(lang, '/');
const items = [ const items = [
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
{ href: localePath(lang, '/cipherverifier'), label: ui.nav.product }, { href: localePath(lang, '/cipherverifier'), label: ui.nav.product },
{ href: `${home}#sectors`, label: ui.nav.sectors },
{ href: localePath(lang, '/insights'), label: ui.nav.insights }, { href: localePath(lang, '/insights'), label: ui.nav.insights },
{ href: localePath(lang, '/team'), label: ui.nav.team }, { href: localePath(lang, '/team'), label: ui.nav.team },
]; ];
@@ -17,89 +15,205 @@ const contactHref = `${home}#contact`;
const path = Astro.url.pathname.replace(/\/$/, '') || '/'; const path = Astro.url.pathname.replace(/\/$/, '') || '/';
const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#'); const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#');
--- ---
<header class="header" data-header> <header class="header" data-header data-tone={tone} data-tone-init>
<div class="container header__inner"> <div class="container header__shell">
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}><Wordmark /></a> <div class="header__bar hud-frame">
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}>
<Wordmark inline class="header__logo header__logo--paper" />
<Wordmark inline light class="header__logo header__logo--night" />
</a>
<nav class="header__nav" aria-label="Primary" id="primary-nav"> <nav class="header__nav hud-frame" aria-label="Primary" id="primary-nav">
<ul class="header__list" role="list"> <ul class="header__list" role="list">
{items.map((item) => ( {items.map((item) => {
<li><a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>{item.label}</a></li> const current = isCurrent(item.href);
))} return (
<li><a class:list={[{ 'hud-corners': current }]} href={item.href} aria-current={current ? 'page' : undefined}>{item.label}</a></li>
);
})}
<li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li> <li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li>
</ul> </ul>
</nav> </nav>
<a class="button button--small header__contact" href={contactHref}>{ui.nav.contact}</a> <a class="header__contact" href={contactHref}>{ui.nav.contact}</a>
<button class="header__toggle" type="button" aria-expanded="false" aria-controls="primary-nav" data-nav-toggle> <button class="header__toggle" type="button" aria-expanded="false" aria-controls="primary-nav" data-nav-toggle>
<span data-label-open>{ui.nav.menu}</span> <span data-label-open>{ui.nav.menu}</span>
<span data-label-close hidden>{ui.nav.close}</span> <span data-label-close hidden>{ui.nav.close}</span>
</button> </button>
</div> </div>
</div>
</header> </header>
<style> <style>
/* The header is a transparent sticky strip; the bar floats inside it with room on every side
for the corner crosses. Only the bar takes pointer events, so the margin never blocks clicks. */
.header { .header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
background: var(--paper); pointer-events: none;
border-bottom: var(--hairline) solid var(--line);
} }
.header__inner { .header__shell {
padding-block: calc(var(--space-3) + var(--safe-top)) var(--space-3);
transition:
max-width var(--dur-slow) var(--ease),
padding var(--dur-slow) var(--ease);
}
/* Once the page scrolls, the bar docks: full width, flush to the top, and as tall as the header
so nothing below moves. Its outer crosses would sit off-screen (and the side ones would make
the page scroll sideways), so they go, with the top and side borders; the bottom line stays. */
.header[data-scrolled] .header__shell {
max-width: 100%;
padding: 0;
}
/* Docked, the glass also fills the status-bar strip, so scrolled content never shows behind the
clock; the bar's contents stay below it. */
.header[data-scrolled] .header__bar {
min-height: var(--header-offset);
padding-top: var(--safe-top);
border-width: 0 0 var(--line);
}
.header[data-scrolled] .header__bar::after { content: none; }
.header__bar {
pointer-events: auto;
isolation: isolate;
display: flex;
align-items: stretch;
min-height: calc(var(--header-height) - 2 * var(--space-3));
color: var(--ink-base);
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;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease),
min-height var(--dur-slow) var(--ease);
}
/* The glass sits on a pseudo-element rather than the bar itself: a backdrop-filter on the bar
would make it a backdrop root, and the mobile panel inside it could then blur nothing. */
.header__bar::before,
.header__nav[data-open]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
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 {
background: var(--glass);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
}
}
/* Over a night section the bar takes the night tone. */
.header[data-tone='night'] { --hud-line: var(--hud-line-on-night); }
.header[data-tone='night'] .header__bar { color: var(--ink-on-night); }
.header[data-tone='night'] .header__bar::before,
.header[data-tone='night'] .header__nav[data-open]::before { background: var(--glass-solid-on-night); }
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.header[data-tone='night'] .header__bar::before,
.header[data-tone='night'] .header__nav[data-open]::before { background: var(--glass-on-night); }
}
.header__home {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; padding-inline: var(--space-4);
gap: var(--gap);
min-height: var(--header-height);
}
.header__home { text-decoration: none; }
.header__nav { margin-left: auto; }
.header__list {
display: flex;
gap: var(--space-6);
}
.header__list a {
text-decoration: none; text-decoration: none;
font-weight: var(--weight-regular); }
padding-block: var(--space-2); .header__home :global(.header__logo--night),
border-bottom: var(--hairline) solid transparent; .header[data-tone='night'] .header__home :global(.header__logo--paper) { display: none; }
transition: border-color var(--dur-fast) var(--ease); .header[data-tone='night'] .header__home :global(.header__logo--night) { display: block; }
/* Cells: every item is a full-height cell, divided from its neighbour by one frame line. */
.header__nav { margin-left: auto; display: flex; }
/* On desktop the nav is just a run of cells inside the bar: no frame of its own. */
.header__nav.hud-frame { border: 0; }
.header__nav.hud-frame::after { content: none; }
.header__list { display: flex; }
.header__list li { display: flex; border-left: var(--line) solid var(--frame-color); }
.header__list a,
.header__contact,
.header__toggle {
display: flex;
align-items: center;
padding-inline: var(--space-4);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease);
} }
.header__list a:hover, .header__list a:hover,
.header__list a[aria-current='page'] { border-bottom-color: currentColor; } .header__toggle:hover { background: color-mix(in srgb, currentColor var(--tint-hover), transparent); }
.header__contact { margin-left: var(--space-6); } .header__list a[aria-current='page'] { background: color-mix(in srgb, currentColor var(--tint-active), transparent); }
.header__contact-mobile { display: none; } .header__contact {
border-left: var(--line) solid var(--frame-color);
background: var(--ink-base);
color: var(--paper);
}
.header__contact:hover { background: var(--ink-muted-base); }
.header[data-tone='night'] .header__contact { background: var(--paper); color: var(--night); }
.header[data-tone='night'] .header__contact:hover { background: var(--paper-deep); }
.header__list .header__contact-mobile { display: none; }
.header__toggle { .header__toggle {
display: none; display: none;
font-weight: var(--weight-medium); border-left: var(--line) solid var(--frame-color);
padding: var(--space-2) 0; text-transform: inherit;
letter-spacing: inherit;
}
/* Focus ring drawn inside the cell, so it never collides with the frame lines. */
.header__bar :focus-visible {
outline-offset: calc(-1 * var(--space-1));
border-radius: 0;
}
/* A panel left open across a resize to desktop must not paint a second glass layer. */
@media (min-width: 48rem) {
.header__nav[data-open]::before { content: none; }
} }
@media (max-width: 47.99rem) { @media (max-width: 47.99rem) {
.header__contact { display: none; } .header__contact { display: none; }
.header__contact-mobile { display: block; } .header__list .header__contact-mobile { display: flex; }
.header__toggle { display: inline-block; } .header__toggle { display: flex; margin-left: auto; }
.header__nav { .header__nav {
position: absolute; position: absolute;
inset: 100% 0 auto 0; top: calc(100% + var(--space-3));
left: calc(-1 * var(--line));
right: calc(-1 * var(--line));
display: none; display: none;
padding: var(--space-5) var(--gutter) var(--space-7);
background: var(--paper);
border-bottom: var(--hairline) solid var(--line);
} }
/* As the mobile panel it is a full .hud-frame: border and corner crosses. */
.header__nav.hud-frame { border: var(--line) solid var(--frame-color); }
.header__nav.hud-frame::after { content: ''; }
.header__nav[data-open] { display: block; } .header__nav[data-open] { display: block; }
.header__list { flex-direction: column; gap: 0; } /* Docked: the panel hangs straight off the bar's bottom line, edge to edge. */
.header__list li + li { border-top: var(--hairline) solid var(--line); } .header[data-scrolled] .header__nav {
top: calc(100% + var(--line));
border-width: 0 0 var(--line);
}
.header[data-scrolled] .header__nav.hud-frame::after { content: none; }
.header__list { flex-direction: column; }
.header__list li { border-left: 0; }
.header__list li + li { border-top: var(--line) solid var(--frame-color); }
.header__list a { .header__list a {
display: block; flex: 1;
padding-block: var(--space-4); padding-block: var(--space-4);
font-size: var(--step-h3); font-size: var(--step-small);
letter-spacing: var(--track-h3);
border-bottom: 0;
} }
.header__list a[aria-current='page'] { font-weight: var(--weight-medium); }
} }
</style> </style>
@@ -120,4 +234,56 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); });
nav.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) set(false); }); nav.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) set(false); });
} }
/* Scrolled: a one-pixel marker at the very top of the document; once it leaves the viewport
the page has scrolled. An IntersectionObserver, so nothing runs on scroll. */
if (header && 'IntersectionObserver' in window) {
const marker = document.createElement('div');
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;';
document.body.prepend(marker);
new IntersectionObserver(([entry]) => {
header.toggleAttribute('data-scrolled', !entry.isIntersecting);
}).observe(marker);
}
/* 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');
/* The browser chrome follows the bar: theme-color tints the strip around the iPhone's notch and
clock (and the toolbar in Chrome on Android), so it changes with the tone underneath. */
const themeColor = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
const tokens = getComputedStyle(document.documentElement);
const chrome = (tone: string) => {
const value = tokens.getPropertyValue(tone === 'night' ? '--night' : '--paper').trim();
if (themeColor && value && themeColor.content !== value) themeColor.content = value;
};
// 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;
const watch = () => {
observer?.disconnect();
under.clear();
const mid = Math.round(header.offsetHeight / 2);
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target);
}
header.dataset.tone = under.size ? 'night' : 'paper';
chrome(header.dataset.tone);
if (header.hasAttribute('data-tone-init')) settle();
}, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` });
sections.forEach((section) => observer!.observe(section));
};
watch();
let resize: number | undefined;
window.addEventListener('resize', () => {
window.clearTimeout(resize);
resize = window.setTimeout(watch, 150);
});
} else {
settle();
}
</script> </script>
+23
View File
@@ -0,0 +1,23 @@
---
/**
* The Matomo tracker: a page view per page, outgoing links and downloads, no cookies, Do Not
* Track honoured. matomo.js loads async from our own Matomo, so it never holds up the page.
*/
import { matomo } from '../data/analytics';
const { url, siteId, enabled } = matomo;
---
{enabled && (
<script is:inline define:vars={{ url, siteId }}>
var _paq = (window._paq = window._paq || []);
_paq.push(['setDoNotTrack', true]);
_paq.push(['disableCookies']);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
_paq.push(['setTrackerUrl', url + 'matomo.php']);
_paq.push(['setSiteId', siteId]);
var s = document.createElement('script');
s.async = true;
s.src = url + 'matomo.js';
document.head.appendChild(s);
</script>
)}
+63
View File
@@ -0,0 +1,63 @@
---
/**
* The Matomo opt-out on the privacy page, in the site's own type rather than Matomo's embed. It
* asks the tracker already on the page: optUserOut stores the one cookie the tracker keeps with
* cookies disabled (mtm_consent_removed), forgetUserOptOut removes it. If the tracker never
* arrives (a content blocker, or a development build), the page says so: nothing is counted.
*/
import { t, type Lang } from '../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const o = t(lang).legal.optOut;
---
<div class="optout" data-optout data-status-in={o.in} data-status-out={o.out} data-status-dnt={o.dnt} data-status-missing={o.missing} data-label-out={o.stop} data-label-in={o.resume}>
<p class="optout__status" role="status" data-optout-status>{o.checking}</p>
<button class="button button--small" type="button" data-optout-toggle hidden></button>
<noscript><p class="optout__status">{o.noscript}</p></noscript>
</div>
<style>
.optout { display: grid; gap: var(--space-4); justify-items: start; }
.optout__status { color: var(--ink-muted); }
.optout .button { font: inherit; font-size: var(--step-small); font-weight: var(--weight-medium); border: 0; cursor: pointer; }
</style>
<script>
type Tracker = { isUserOptedOut(): boolean; optUserOut(): void; forgetUserOptOut(): void };
type Paq = { push(cmd: unknown[]): void };
const root = document.querySelector<HTMLElement>('[data-optout]');
const status = root?.querySelector<HTMLElement>('[data-optout-status]');
const toggle = root?.querySelector<HTMLButtonElement>('[data-optout-toggle]');
if (root && status && toggle) {
const d = root.dataset;
const dnt = navigator.doNotTrack === '1' || (window as { doNotTrack?: string }).doNotTrack === '1';
let tracker: Tracker | undefined;
const render = () => {
if (dnt) { status.textContent = d.statusDnt!; toggle.hidden = true; return; }
if (!tracker) { status.textContent = d.statusMissing!; toggle.hidden = true; return; }
const out = tracker.isUserOptedOut();
status.textContent = out ? d.statusOut! : d.statusIn!;
toggle.textContent = out ? d.labelIn! : d.labelOut!;
toggle.hidden = false;
};
toggle.addEventListener('click', () => {
if (!tracker) return;
if (tracker.isUserOptedOut()) tracker.forgetUserOptOut(); else tracker.optUserOut();
render();
});
// A function pushed onto _paq runs with the tracker as `this` once matomo.js has loaded.
const paq = (window as { _paq?: Paq })._paq;
if (paq && !dnt) {
paq.push([function (this: Tracker) { tracker = this; render(); }]);
// If it has not arrived in a few seconds, it is blocked or not part of this build.
window.setTimeout(() => { if (!tracker) render(); }, 4000);
} else {
render();
}
}
</script>
+126
View File
@@ -0,0 +1,126 @@
---
/** A form's answer, as a toast in the lower right. Framed like the menu, on night. Driven by
scripts/send-form.ts, which finds it by the id of the form it answers. */
interface Props {
for: string;
labels: { sending: string; sent: string; failed: string; close: string };
}
const { for: formId, labels } = Astro.props;
---
<div
class="toast hud-frame"
data-toast={formId}
data-label-sending={labels.sending}
data-label-sent={labels.sent}
data-label-failed={labels.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>{labels.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>
<style>
/* 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; }
/* With the head alone (while sending), it sits centred in the panel. */
.toast:has(.toast__message:empty) { padding-bottom: var(--space-4); }
/* 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; }
}
</style>
+23 -9
View File
@@ -1,16 +1,30 @@
--- ---
/** The wordmark is set in type. No symbol, no colour. */ /** The logo, as supplied. Sized by its height so it sits on the header's baseline grid.
interface Props { class?: string } `inline` writes it into the page as a data URI, so it paints with the first frame instead of
const { class: className } = Astro.props; blinking in on every navigation; the header needs that, the footer does not. */
import logoInline from '../assets/logo.webp?inline';
import logoUrl from '../assets/logo.webp?url';
/* Light lettering for dark surfaces, derived from the supplied logo; the mark is unchanged. */
import logoLightInline from '../assets/logo-light.webp?inline';
import logoLightUrl from '../assets/logo-light.webp?url';
interface Props { class?: string; inline?: boolean; light?: boolean }
const { class: className, inline = false, light = false } = Astro.props;
const src = light ? (inline ? logoLightInline : logoLightUrl) : (inline ? logoInline : logoUrl);
--- ---
<span class:list={['wordmark', className]}>CipherCell</span> <img
class:list={['wordmark', className]}
src={src}
alt="CipherCell"
width="311"
height="72"
loading={inline ? undefined : 'lazy'}
/>
<style> <style>
.wordmark { .wordmark {
display: inline-block; display: block;
font-weight: var(--weight-medium); height: var(--space-5);
letter-spacing: var(--track-h3); width: auto;
font-size: var(--step-h3);
line-height: 1;
} }
</style> </style>
+26 -1
View File
@@ -1,4 +1,5 @@
--- ---
import Toast from '../Toast.astro';
import { t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
@@ -16,7 +17,18 @@ const f = ui.contact.form;
</address> </address>
</div> </div>
<form class="contact__form" action="/contact" method="post" lang={lang}> <form
class="contact__form"
id="contact-form"
action="/contact"
method="post"
lang={lang}
data-contact
data-sent={f.sent}
data-invalid={f.invalid}
data-failed={f.failed}
>
<input type="hidden" name="lang" value={lang} />
<p class="visually-hidden" aria-hidden="true"> <p class="visually-hidden" aria-hidden="true">
<label for="website">{f.honeypot}</label> <label for="website">{f.honeypot}</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" /> <input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
@@ -41,8 +53,11 @@ const f = ui.contact.form;
<button class="button" type="submit">{f.submit}</button> <button class="button" type="submit">{f.submit}</button>
<p class="small muted"><span aria-hidden="true">* </span>{f.required}</p> <p class="small muted"><span aria-hidden="true">* </span>{f.required}</p>
</div> </div>
<noscript><p class="field--full small">{f.noscript}</p></noscript>
</form> </form>
</div> </div>
<Toast for="contact-form" labels={{ ...f.toast }} />
</section> </section>
<style> <style>
@@ -89,6 +104,8 @@ const f = ui.contact.form;
margin-top: var(--space-2); margin-top: var(--space-2);
} }
.contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.contact__text { grid-column: 1 / span 5; } .contact__text { grid-column: 1 / span 5; }
.contact__form { grid-column: 7 / span 6; } .contact__form { grid-column: 7 / span 6; }
@@ -98,3 +115,11 @@ const f = ui.contact.form;
.field { grid-column: 1 / -1; } .field { grid-column: 1 / -1; }
} }
</style> </style>
<script>
// Sends in place and answers in the toast; see scripts/send-form.ts. Without scripting the
// server refuses the post and redirects to /contact/error, which points to the email address.
import { sendInPlace } from '../../scripts/send-form';
const form = document.querySelector<HTMLFormElement>('[data-contact]');
if (form) sendInPlace(form);
</script>
+36 -52
View File
@@ -1,6 +1,6 @@
--- ---
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import plant from '../../assets/images/plant-at-dusk.jpg'; import poster from '../../assets/images/hero-field.png';
import { t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
@@ -9,33 +9,26 @@ const ui = t(lang);
--- ---
<section class="hero night" aria-labelledby="hero-title"> <section class="hero night" aria-labelledby="hero-title">
<div class="hero__media"> <div class="hero__media">
<!-- The still is the video's first frame, so it carries the hero until the video is ready <!-- Abstract and decorative, so neither layer carries alt text. The poster is a frame of
and stays put for anyone who never gets one: no script, reduced motion, saved data. --> the noise field, so it carries the hero until the canvas is drawing, and stays for
anyone who never gets one: reduced motion, no WebGL, no script. -->
<div class="hero__layers">
<Picture <Picture
src={plant} src={poster}
alt={ui.hero.imageAlt} alt=""
quality={70}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[640, 960, 1440, 1920, 2400, 3024]} widths={[640, 960, 1440, 1920, 2400]}
sizes="100vw" sizes="100vw"
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
decoding="async" decoding="async"
class="hero__img img-cover img-doc" class="hero__img img-cover"
pictureAttributes={{ class: 'hero__picture' }} pictureAttributes={{ class: 'hero__picture' }}
/> />
<video <canvas class="hero__field" aria-hidden="true" data-hero-field></canvas>
class="hero__video img-cover img-doc" </div>
muted
loop
playsinline
disablepictureinpicture
preload="none"
aria-hidden="true"
data-hero-video
data-mp4="/video/hero-plant.mp4"
data-webm="/video/hero-plant.webm"
></video>
<div class="hero__overlay"></div> <div class="hero__overlay"></div>
</div> </div>
@@ -53,8 +46,10 @@ const ui = t(lang);
position: relative; position: relative;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
/* Fills the screen under the header, and never less than the headline needs. */ /* Runs up under the floating header so the glass bar sits on the image, fills the screen,
min-height: max(28rem, calc(100svh - var(--header-height))); and is never less than the headline needs. */
margin-top: calc(-1 * var(--header-offset));
min-height: max(28rem, 100svh);
overflow: hidden; overflow: hidden;
} }
.hero__media { position: absolute; inset: 0; } .hero__media { position: absolute; inset: 0; }
@@ -64,22 +59,24 @@ const ui = t(lang);
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: 60% 50%; object-position: 50% 50%;
}
.hero__layers {
position: absolute;
inset: 0;
transform-origin: 50% 60%; transform-origin: 50% 60%;
animation: hero-settle var(--dur-hero) var(--ease) both; animation: hero-settle var(--dur-hero) var(--ease) both;
} }
.hero__video { .hero__field {
position: absolute; position: absolute;
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
object-position: 60% 50%;
opacity: 0; opacity: 0;
transition: opacity var(--dur-slow) var(--ease); transition: opacity var(--dur-slow) var(--ease);
} }
/* Set once the video can actually play, so the still never cuts to black. */ /* Set by the first drawn frame, so the poster never cuts to black. */
.hero__video[data-ready] { opacity: 1; } .hero__field[data-ready] { opacity: 1; }
.hero__overlay { .hero__overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -90,7 +87,10 @@ const ui = t(lang);
width: 100%; width: 100%;
padding-block: var(--space-9); padding-block: var(--space-9);
} }
.hero__title { max-width: 12em; } .hero__title {
max-width: 12em;
font-size: var(--step-hero);
}
.hero__line { .hero__line {
display: block; display: block;
opacity: 0; opacity: 0;
@@ -108,33 +108,17 @@ const ui = t(lang);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; } .hero__layers, .hero__line { animation: none; opacity: 1; transform: none; }
.hero__video { display: none; } .hero__field { display: none; }
} }
</style> </style>
<script> <script>
/* The sources are attached here rather than in the markup, so the file is only fetched for import { startNoiseField } from '../../scripts/noise-field';
someone who will actually see it move: not under reduced motion, not on saved data, and not
on a phone, where a 21:9 frame is cropped to a sliver and the bytes cost the most. */ const canvas = document.querySelector<HTMLCanvasElement>('[data-hero-field]');
const video = document.querySelector<HTMLVideoElement>('[data-hero-video]'); const hero = canvas?.closest<HTMLElement>('.hero');
const wants = window.matchMedia('(min-width: 48rem)').matches if (canvas && hero && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches; startNoiseField(canvas, hero);
const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
if (video && wants && !connection?.saveData) {
const add = (src: string | null, type: string) => {
if (!src) return;
const source = document.createElement('source');
source.src = src;
source.type = type;
video.append(source);
};
add(video.dataset.webm ?? null, 'video/webm');
add(video.dataset.mp4 ?? null, 'video/mp4');
video.addEventListener('canplay', () => video.setAttribute('data-ready', ''), { once: true });
video.preload = 'auto';
video.load();
/* A blocked autoplay leaves the still in place, which is a fine hero on its own. */
video.play().catch(() => {});
} }
</script> </script>
+29 -27
View File
@@ -1,13 +1,13 @@
--- ---
import { getCollection } from 'astro:content'; import { getFeed } from '../../data/insights';
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import Field from '../Field.astro';
import { localePath, t, type Lang } from '../../i18n/ui'; import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
const posts = (await getCollection('insights')) const feed = (await getFeed()).slice(0, 3);
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()) const m = ui.insights.mention;
.slice(0, 2);
--- ---
<section class="section insights" aria-labelledby="insights-title"> <section class="section insights" aria-labelledby="insights-title">
<div class="container"> <div class="container">
@@ -17,12 +17,13 @@ const posts = (await getCollection('insights'))
</div> </div>
<ul class="insights__list" role="list"> <ul class="insights__list" role="list">
{posts.map((post) => ( {feed.map((item) => (
<li class="insights__item"> <li class="insights__item">
<a class="insights__card" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}> {item.kind === 'post' ? (
<Picture <a class="insights__card" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
src={post.data.cover} {item.entry.data.cover ? <Picture
alt={post.data.coverAlt} src={item.entry.data.cover}
alt={item.entry.data.coverAlt!}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[400, 640, 960]} widths={[400, 640, 960]}
@@ -30,19 +31,19 @@ const posts = (await getCollection('insights'))
loading="lazy" loading="lazy"
decoding="async" decoding="async"
class="insights__img img-doc" class="insights__img img-doc"
/> /> : <Field seed={item.entry.id} class="insights__panel"><span aria-hidden="true">{item.entry.data.category}</span></Field>}
<p class="small muted">{post.data.category}</p> <p class="small muted">{item.entry.data.category}</p>
<h3 class="h3">{post.data.title}</h3> <h3 class="h3">{item.entry.data.title}</h3>
</a> </a>
) : (
<a class="insights__card" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
<Field seed={item.entry.id} class="insights__panel"><span aria-hidden="true">{item.entry.data.source}</span></Field>
<p class="small muted">{m.category(item.entry.data.platform)}</p>
<h3 class="h3">{item.entry.data.title}</h3>
</a>
)}
</li> </li>
))} ))}
<li class="insights__item">
<a class="insights__card insights__card--event" href={ui.insights.event.href} rel="noopener">
<p class="insights__event-when">{ui.insights.event.when}</p>
<p class="small muted">{ui.insights.event.category}</p>
<h3 class="h3">{ui.insights.event.text}</h3>
</a>
</li>
</ul> </ul>
</div> </div>
</section> </section>
@@ -68,20 +69,21 @@ const posts = (await getCollection('insights'))
height: 100%; height: 100%;
} }
.insights__card:hover h3 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; } .insights__card:hover h3 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; }
.insights__img, :global(.insights__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; margin-bottom: var(--space-2); } .insights__img, :global(.insights__img) { width: 100%; height: auto; aspect-ratio: 4 / 1; object-fit: cover; object-position: 50% 15%; margin-bottom: var(--space-2); }
.insights__card h3 { max-width: 18em; } .insights__card h3 { max-width: 18em; }
.insights__event-when { /* No picture: a colour field carries the source in large type, centred in the short strip,
display: flex; with the field's darkening moved behind it. */
align-items: flex-end; :global(.insights__panel) {
aspect-ratio: 3 / 2; --field-scrim: var(--field-scrim-centre);
padding: var(--space-5); aspect-ratio: 4 / 1;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
background: var(--paper-deep);
color: var(--ink-muted);
font-size: var(--step-title); font-size: var(--step-title);
line-height: var(--leading-title); line-height: var(--leading-title);
letter-spacing: var(--track-h2); letter-spacing: var(--track-h2);
} }
/* Field's own scoped rule (a class and an attribute) puts its text at the bottom; three
classes outrank it whatever order the stylesheets load in. */
:global(.insights__card .field.insights__panel) { justify-content: center; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); } .insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
+20 -10
View File
@@ -1,15 +1,15 @@
--- ---
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import MethodDiagram from './MethodDiagram.astro'; import MethodDiagram from './MethodDiagram.astro';
import engineer from '../../assets/images/engineer-glass.jpg'; import monitoringRoom from '../../assets/images/monitoring-room-glass.jpg';
import { localePath, t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
--- ---
<section class="night method" id="capabilities" aria-labelledby="method-title"> <section class="night method" aria-labelledby="method-title">
<Picture <Picture
src={engineer} src={monitoringRoom}
alt={ui.method.imageAlt} alt={ui.method.imageAlt}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
@@ -21,13 +21,13 @@ const ui = t(lang);
pictureAttributes={{ class: 'method__picture' }} pictureAttributes={{ class: 'method__picture' }}
/> />
<div class="container grid method__intro"> <!-- The footer's pillar links land here, where the dark area begins, rather than on the image. -->
<div class="container grid method__intro" id="capabilities">
<h2 id="method-title" class="h2 method__title"> <h2 id="method-title" class="h2 method__title">
{ui.method.lines.map((line, i) => <span class:list={['line', { 'line--muted': i > 0 }]}>{line}</span>)} {ui.method.lines.map((line, i) => <span class:list={['line', { 'line--muted': i > 0 }]}>{line}</span>)}
</h2> </h2>
<div class="method__body"> <div class="method__body">
<p>{ui.method.body}</p> <p>{ui.method.body.map((line) => <span class="line">{line}</span>)}</p>
<a class="button" href={localePath(lang, '/cipherverifier')}>{ui.method.cta}</a>
</div> </div>
</div> </div>
@@ -51,11 +51,21 @@ const ui = t(lang);
align-items: start; align-items: start;
} }
.method__title { grid-column: 1 / -1; } .method__title { grid-column: 1 / -1; }
.method__body { grid-column: 1 / -1; max-width: 30em; display: grid; gap: var(--space-5); justify-items: start; } .method__body { grid-column: 1 / -1; max-width: 30em; text-wrap: balance; }
.method__figure { padding-bottom: var(--section); } .method__figure { padding-bottom: var(--section); }
.method__figure :global(.diagram) { max-width: 78rem; margin-inline: auto; } .method__figure :global(.diagram) { margin-inline: auto; }
.method__figure :global(.diagram--wide) { max-width: 78rem; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.method__title { grid-column: 1 / span 6; } .method__title { grid-column: 1 / span 6; }
.method__body { grid-column: 7 / span 5; padding-top: var(--space-2); } /* Short text: it sits on the headline's last line rather than hanging from its top, set flush
right against the grid's edge, with a bar in the text colour along its full height. */
.method__body {
grid-column: 7 / -1;
justify-self: end;
align-self: end;
text-align: right;
padding-right: var(--space-4);
border-right: var(--bar) solid currentColor;
}
} }
</style> </style>
+74 -80
View File
@@ -1,8 +1,11 @@
--- ---
/** /**
* The method as a closed loop of four stages, drawn as a spec-sheet diagram. * The method as a triangle of three pillars, drawn as a spec-sheet diagram.
* Main nodes sit on the corners of the loop with their labels inside; * Every pillar is joined to the other two; each edge carries arrows both ways, since the pillars feed each other.
* each stage's instruments fan outwards. Wide and tall compositions of the same drawing. * Each pillar's instruments fan outwards. Below 64rem a compact version shows the triangle alone,
* without the instruments, which would scale too small to read.
*
* Hovering a pillar brings its instruments forward: lines, dots and labels take the brighter tone.
*/ */
interface Stage { title: string; items: string[] } interface Stage { title: string; items: string[] }
interface Props { title: string; desc: string; stages: Stage[] } interface Props { title: string; desc: string; stages: Stage[] }
@@ -10,98 +13,77 @@ const { title, desc, stages } = Astro.props;
const stroke = 1.25; const stroke = 1.25;
const ring = 36; const ring = 36;
type Point = { x: number; y: number };
/* Wide composition: 1400 x 560. Nodes on the corners of a loop. */ /* Direction tick: a small chevron near the midpoint of an edge, pointing along it, set `gap` past the midpoint. */
const W = { w: 1400, h: 560, x1: 420, x2: 980, y1: 140, y2: 420, sat: 175, dy: 62 }; const tick = (a: Point, b: Point, s = 7, gap = 0) => {
const wideNodes = [ const dx = b.x - a.x, dy = b.y - a.y;
{ x: W.x1, y: W.y1, side: -1, up: -1 }, // top-left: threat analysis
{ x: W.x2, y: W.y1, side: 1, up: -1 }, // top-right: engineering
{ x: W.x2, y: W.y2, side: 1, up: 1 }, // bottom-right: verification
{ x: W.x1, y: W.y2, side: -1, up: 1 }, // bottom-left: monitoring
];
/* Tall composition: 400 x 780. Nodes stacked on a spine, the loop closes on the left.
The title sits right of the node, its instruments hang below the title. */
const T = { w: 400, h: 780, x: 60, loopX: 20, y0: 60, step: 200, ring: 28, labelX: 40, satX: 44, satY: 36, dy: 28 };
const tallNodes = stages.map((_, i) => ({ x: T.x, y: T.y0 + i * T.step }));
/* Direction tick: a small chevron at the midpoint of an edge, pointing along it. */
const tick = (x: number, y: number, dx: number, dy: number, s = 7) => {
const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len; const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len;
const x = (a.x + b.x) / 2 + ux * gap, y = (a.y + b.y) / 2 + uy * gap;
const px = -uy, py = ux; const px = -uy, py = ux;
return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`; return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`;
}; };
/* Both ways: a pair of chevrons either side of the midpoint, pointing away from each other. */
const ticks = (a: Point, b: Point, s = 7, gap = 20) => [tick(a, b, s, gap), tick(b, a, s, gap)];
const pts = (list: Point[]) => list.map((p) => `${p.x},${p.y}`).join(' ');
/* Wide composition: 1400 x 560. Security on the apex, engineering bottom right, research bottom left.
Labels sit outside the triangle: above the apex, below the base corners. */
const W = { w: 1400, h: 560, sat: 160, dy: 62 };
const wideNodes = [
{ x: 680, y: 110, side: -1, up: -1 },
{ x: 950, y: 440, side: 1, up: 1 },
{ x: 410, y: 440, side: -1, up: 1 },
];
const wideEdges = wideNodes.map((n, i) => [n, wideNodes[(i + 1) % wideNodes.length]]);
/* Compact composition: 600 x 480. The same triangle with the pillar titles only; the labels are set
larger so they still read at phone width. */
const C = { w: 600, h: 480, label: 22 };
const compactNodes = [
{ x: 300, y: 80, up: -1 },
{ x: 520, y: 390, up: 1 },
{ x: 80, y: 390, up: 1 },
];
const compactEdges = compactNodes.map((n, i) => [n, compactNodes[(i + 1) % compactNodes.length]]);
--- ---
<svg class="diagram diagram--wide" viewBox={`0 0 ${W.w} ${W.h}`} role="img" aria-labelledby="method-w-title method-w-desc" xmlns="http://www.w3.org/2000/svg"> <svg class="diagram diagram--wide" viewBox={`0 0 ${W.w} ${W.h}`} role="img" aria-labelledby="method-w-title method-w-desc" xmlns="http://www.w3.org/2000/svg">
<title id="method-w-title">{title}</title> <title id="method-w-title">{title}</title>
<desc id="method-w-desc">{desc}</desc> <desc id="method-w-desc">{desc}</desc>
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke"> <g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke">
<rect x={W.x1} y={W.y1} width={W.x2 - W.x1} height={W.y2 - W.y1} /> <polygon points={pts(wideNodes)} />
<polyline points={tick((W.x1 + W.x2) / 2, W.y1, 1, 0)} /> {wideEdges.flatMap(([a, b]) => ticks(a, b)).map((t) => <polyline points={t} />)}
<polyline points={tick(W.x2, (W.y1 + W.y2) / 2, 0, 1)} /> </g>
<polyline points={tick((W.x1 + W.x2) / 2, W.y2, -1, 0)} />
<polyline points={tick(W.x1, (W.y1 + W.y2) / 2, 0, -1)} />
{wideNodes.map((n, i) => ( {wideNodes.map((n, i) => (
<g class="diagram__pillar">
{stages[i].items.map((item, k) => (
<g> <g>
<circle cx={n.x} cy={n.y} r={ring} stroke-dasharray="2 5" /> <line x1={n.x} y1={n.y} x2={n.x + n.side * W.sat} y2={n.y + (k - 1) * W.dy} stroke-width={stroke} vector-effect="non-scaling-stroke" class="diagram__branch" />
{stages[i].items.map((_, k) => { <circle cx={n.x + n.side * W.sat} cy={n.y + (k - 1) * W.dy} r="3" class="diagram__dot" />
const sx = n.x + n.side * W.sat; <text class="diagram__meta" x={n.x + n.side * (W.sat + 14)} y={n.y + (k - 1) * W.dy + 5} text-anchor={n.side < 0 ? 'end' : 'start'} font-size="15">{item}</text>
const sy = n.y + (k - 1) * W.dy;
return <line x1={n.x} y1={n.y} x2={sx} y2={sy} class="diagram__branch" />;
})}
</g> </g>
))} ))}
</g> <circle cx={n.x} cy={n.y} r={ring} fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke" stroke-linecap="square" stroke-dasharray="2 5" />
<g fill="currentColor"> <circle cx={n.x} cy={n.y} r={ring + 8} class="diagram__hit" />
{wideNodes.map((n, i) => ( <circle cx={n.x} cy={n.y} r="5" fill="currentColor" />
<g> <text class="diagram__label" x={n.x} y={n.up < 0 ? n.y - ring - 16 : n.y + ring + 28} text-anchor="middle" font-size="17" font-weight="500">{stages[i].title}</text>
<circle cx={n.x} cy={n.y} r="5" />
{stages[i].items.map((_, k) => <circle cx={n.x + n.side * W.sat} cy={n.y + (k - 1) * W.dy} r="3" class="diagram__dot" />)}
</g> </g>
))} ))}
</g>
<g class="diagram__labels" font-size="17" font-weight="500">
{wideNodes.map((n, i) => (
<text x={n.x - n.side * (ring + 14)} y={n.y - n.up * 30 + 6} text-anchor={n.side < 0 ? 'start' : 'end'}>{stages[i].title}</text>
))}
</g>
<g class="diagram__meta" font-size="15">
{wideNodes.map((n, i) => stages[i].items.map((item, k) => (
<text x={n.x + n.side * (W.sat + 14)} y={n.y + (k - 1) * W.dy + 5} text-anchor={n.side < 0 ? 'end' : 'start'}>{item}</text>
)))}
</g>
</svg> </svg>
<svg class="diagram diagram--tall" viewBox={`0 0 ${T.w} ${T.h}`} role="img" aria-labelledby="method-t-title method-t-desc" xmlns="http://www.w3.org/2000/svg"> <svg class="diagram diagram--compact" viewBox={`0 0 ${C.w} ${C.h}`} role="img" aria-labelledby="method-c-title method-c-desc" xmlns="http://www.w3.org/2000/svg">
<title id="method-t-title">{title}</title> <title id="method-c-title">{title}</title>
<desc id="method-t-desc">{desc}</desc> <desc id="method-c-desc">{desc}</desc>
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke"> <g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke">
<line x1={T.x} y1={tallNodes[0].y} x2={T.x} y2={tallNodes[3].y} /> <polygon points={pts(compactNodes)} />
<polyline points={`${T.x},${tallNodes[3].y} ${T.loopX},${tallNodes[3].y} ${T.loopX},${tallNodes[0].y} ${T.x},${tallNodes[0].y}`} /> {compactEdges.flatMap(([a, b]) => ticks(a, b, 8, 28)).map((t) => <polyline points={t} />)}
{tallNodes.slice(0, 3).map((n) => <polyline points={tick(T.x, n.y + T.step / 2 + 20, 0, 1)} />)} {compactNodes.map((n) => <circle cx={n.x} cy={n.y} r={ring} stroke-dasharray="2 5" />)}
<polyline points={tick(T.loopX, (tallNodes[0].y + tallNodes[3].y) / 2, 0, -1)} />
{tallNodes.map((n, i) => (
<g>
<circle cx={n.x} cy={n.y} r={T.ring} stroke-dasharray="2 5" />
{stages[i].items.map((_, k) => <line x1={n.x} y1={n.y} x2={n.x + T.satX} y2={n.y + T.satY + k * T.dy} class="diagram__branch" />)}
</g>
))}
</g> </g>
<g fill="currentColor"> <g fill="currentColor">
{tallNodes.map((n, i) => ( {compactNodes.map((n) => <circle cx={n.x} cy={n.y} r="6" />)}
<g>
<circle cx={n.x} cy={n.y} r="5" />
{stages[i].items.map((_, k) => <circle cx={n.x + T.satX} cy={n.y + T.satY + k * T.dy} r="3" class="diagram__dot" />)}
</g> </g>
))} <g class="diagram__label" font-size={C.label} font-weight="500" text-anchor="middle">
</g> {compactNodes.map((n, i) => <text x={n.x} y={n.up < 0 ? n.y - ring - 14 : n.y + ring + 30}>{stages[i].title}</text>)}
<g class="diagram__labels" font-size="16" font-weight="500">
{tallNodes.map((n, i) => <text x={n.x + T.labelX} y={n.y + 6}>{stages[i].title}</text>)}
</g>
<g class="diagram__meta" font-size="14">
{tallNodes.map((n, i) => stages[i].items.map((item, k) => (
<text x={n.x + T.satX + 12} y={n.y + T.satY + k * T.dy + 5}>{item}</text>
)))}
</g> </g>
</svg> </svg>
@@ -112,14 +94,26 @@ const tick = (x: number, y: number, dx: number, dy: number, s = 7) => {
color: var(--ink); color: var(--ink);
font-family: var(--font-sans); font-family: var(--font-sans);
} }
.diagram__labels { fill: var(--ink); } .diagram__label { fill: var(--ink); }
.diagram__meta { fill: var(--ink-muted); } .diagram__meta { fill: var(--ink-muted); transition: fill var(--dur-slow) var(--ease); }
.diagram__branch { stroke: var(--line); } .diagram__branch { stroke: var(--line); transition: stroke var(--dur-slow) var(--ease); }
.diagram__dot { fill: var(--ink-muted); } .diagram__dot { fill: var(--ink-muted); transition: fill var(--dur-slow) var(--ease); }
.diagram--tall { display: none; } .diagram__hit { fill: transparent; pointer-events: all; }
/* Below 64rem the wide drawing would scale its labels under 11px; the tall one takes over. */
/* Hovered pillar: its instruments come forward. */
.diagram__pillar:hover .diagram__branch { stroke: var(--ink-muted); }
.diagram__pillar:hover .diagram__dot,
.diagram__pillar:hover .diagram__meta { fill: var(--ink); }
@media (prefers-reduced-motion: reduce) {
.diagram__meta, .diagram__branch, .diagram__dot { transition: none; }
}
.diagram--compact { display: none; }
/* Below 64rem the wide drawing would scale its labels under 11px; the compact one takes over. */
@media (max-width: 63.99rem) { @media (max-width: 63.99rem) {
.diagram--wide { display: none; } .diagram--wide { display: none; }
.diagram--tall { display: block; max-width: 26rem; } .diagram--compact { display: block; max-width: 26rem; }
} }
</style> </style>
+35
View File
@@ -0,0 +1,35 @@
---
/* One partner logo, two states. At rest: an ink silhouette (the image's alpha is the mask), so a
logo drawn for a dark ground reads as print on paper. On hover of the surrounding
`[data-partner]`: the original colours on night, the ground they were drawn for. */
interface Props { name: string; src: ImageMetadata; url: string; h: string }
const { name, src, url, h } = Astro.props;
---
<span class="logo" style={`--logo: url(${url}); --logo-h: ${h}; --ratio: ${src.width / src.height};`}>
<span class="logo__ink" aria-hidden="true"></span>
<img class="logo__original" src={url} alt={name} width={src.width} height={src.height} loading="lazy" decoding="async" />
</span>
<style>
.logo { display: grid; justify-items: start; width: 100%; }
/* The set height, or smaller where the column is too narrow for it. */
.logo > * { grid-area: 1 / 1; width: min(100%, var(--logo-h) * var(--ratio)); height: auto; aspect-ratio: var(--ratio); }
.logo__ink {
display: block;
background: var(--ink);
mask: var(--logo) left center / contain no-repeat;
transition: opacity var(--dur-fast) var(--ease);
}
/* Kept in the layout for its alt text; shown only on hover. */
.logo__original {
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
@media (hover: hover) {
:global([data-partner]:hover) .logo__ink { opacity: 0; }
:global([data-partner]:hover) .logo__original { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.logo__ink, .logo__original { transition: none; }
}
</style>
+100
View File
@@ -0,0 +1,100 @@
---
import { getImage } from 'astro:assets';
import { t, type Lang } from '../../i18n/ui';
import PartnerLogo from './PartnerLogo.astro';
import incubator from '../../assets/partners/xg-incubator.webp';
import aloe from '../../assets/partners/xg-aloe.webp';
import passau from '../../assets/partners/university-of-passau.webp';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
/* The partner logos as on ciphercell.de (see PartnerLogo.astro for the two states). `h` balances
optical size: the incubator wordmark is long and thin, the other two carry a mark. */
const roles = ui.partners.roles;
const logos = await Promise.all([
{ name: 'xG-Incubator', role: roles.incubator, src: incubator, h: 'var(--space-5)' },
{ name: 'xG-ALOE', role: roles.aloe, src: aloe, h: 'var(--space-7)' },
{ name: lang === 'de' ? 'Universität Passau' : 'University of Passau', role: roles.passau, src: passau, h: 'var(--space-7)' },
].map(async (l) => ({ ...l, url: (await getImage({ src: l.src, format: 'webp' })).src })));
---
<section class="section section--tight partners" aria-labelledby="partners-title">
<div class="container grid">
<div class="partners__block">
<h2 class="small muted partners__title" id="partners-title">{ui.partners.title}</h2>
<ul class="partners__list" role="list">
{logos.map((l) => (
<li class="partners__item" data-partner>
<PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} />
<p class="partners__text">
<span class="partners__name">{l.name}</span>
<span class="small partners__role">{l.role}</span>
</p>
</li>
))}
</ul>
</div>
</div>
</section>
<style>
.partners__title { font-weight: var(--weight-regular); }
/* Hover answers the pointer with the logo's own ground: night, original colours. */
[data-partner] { transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
@media (hover: hover) {
[data-partner]:hover { background-color: var(--night); color: var(--ink-on-night); }
}
@media (prefers-reduced-motion: reduce) { [data-partner] { transition: none; } }
.partners__block {
grid-column: 1 / -1;
display: grid;
gap: var(--space-5);
}
.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);
}
.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); }
@media (hover: hover) {
.partners__item:hover .partners__role { color: var(--ink-muted-on-night); }
}
@media (min-width: 48rem) {
.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>
-39
View File
@@ -1,39 +0,0 @@
---
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
---
<section class="section proof" aria-labelledby="proof-title">
<div class="container grid">
<h2 id="proof-title" class="h2 proof__title">{ui.proof.title}</h2>
<ul class="proof__list" role="list">
{ui.proof.items.map((p) => (
<li class="proof__item">
<p class="proof__name">{p.name}</p>
<p class="muted proof__body">{p.body}</p>
</li>
))}
</ul>
</div>
</section>
<style>
.proof { border-top: var(--hairline) solid var(--line); }
.proof__title { grid-column: 1 / -1; }
.proof__list { grid-column: 1 / -1; }
.proof__item {
display: grid;
gap: var(--space-2);
padding-block: var(--space-5);
border-top: var(--hairline) solid var(--line);
}
.proof__item:last-child { border-bottom: var(--hairline) solid var(--line); }
.proof__name { font-weight: var(--weight-medium); }
.proof__body { max-width: 30em; }
@media (min-width: 48rem) {
.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); }
}
</style>
+238 -15
View File
@@ -1,28 +1,65 @@
--- ---
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import felix from '../../assets/images/people/felix.png'; import felix from '../../assets/images/people/felix.png';
import florian from '../../assets/images/people/florian.png';
import { localePath, t, type Lang } from '../../i18n/ui'; import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
const essay = localePath(lang, '/insights/xg-aloe-user-forum'); // Where each statement's eyebrow leads, and whose portrait sits beside it. Same order as ui.quote.items.
const sources = [
{ href: localePath(lang, '/insights/xg-aloe-user-forum'), portrait: felix },
{ href: `${localePath(lang, '/team')}#research-title`, portrait: florian },
];
const items = ui.quote.items.map((item, i) => ({ ...item, ...sources[i] }));
const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}”`);
--- ---
<section class="section--tight quote" aria-labelledby="quote-text"> <section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
<div class="container"> <div class="container">
<figure class="quote__figure"> <div class="quote__frame" data-frame>
<div class="quote__panel"> <div class="quote__panel">
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p> <div class="quote__stack" aria-live="off" data-live>
{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> <blockquote>
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p> <p class="h2 quote__text">{quoted(item.text)}</p>
</blockquote> </blockquote>
<figcaption class="quote__caption"> <figcaption class="quote__caption">
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p> <span class="quote__name">{item.name}</span><br /><span class="muted">{item.role}</span>
</figcaption> </figcaption>
</figure>
))}
</div> </div>
<div class="quote__portrait"> <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__portraits">
{items.map((item, i) => (
<div class="quote__portrait" data-portrait data-active={i === 0 ? '' : undefined}>
<Picture <Picture
src={felix} src={item.portrait}
alt={ui.quote.portraitAlt} alt={item.portraitAlt}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[480, 640, 960, 1200]} widths={[480, 640, 960, 1200]}
@@ -33,30 +70,216 @@ const essay = localePath(lang, '/insights/xg-aloe-user-forum');
pictureAttributes={{ class: 'quote__picture' }} pictureAttributes={{ class: 'quote__picture' }}
/> />
</div> </div>
</figure> ))}
</div>
</div>
</div> </div>
</section> </section>
<style> <style>
.quote__figure { .quote {
/* One statement holds for this long before the next one comes in. */
--quote-hold: 9s;
}
.quote__frame {
display: grid; display: grid;
background: var(--paper-deep); background: var(--paper-deep);
} }
/* The whole card advances on click once the script has set it up. */
.quote[data-ready] .quote__frame { cursor: pointer; }
.quote__panel { .quote__panel {
display: grid; display: grid;
gap: var(--space-7); gap: var(--space-7);
align-content: space-between; align-content: space-between;
padding: var(--space-7) var(--space-6); padding: var(--space-7) var(--space-6);
} }
.quote__essay { text-decoration: none; }
.quote__essay:hover { text-decoration: underline; } /* Slides share one grid cell, so the frame takes the height of the longest and never jumps. */
.quote__stack, .quote__portraits { display: grid; }
.quote__slide, .quote__portrait { grid-area: 1 / 1; }
/*
* The change: the outgoing statement lifts away, then the incoming one rises in line by line,
* eyebrow, quote, caption. The new portrait fades in over the old one and settles from a slight
* scale, as the hero image does on load.
*/
.quote__slide {
display: grid;
gap: var(--space-7);
align-content: space-between;
margin: 0;
visibility: hidden;
transition: visibility 0s linear var(--dur-slow);
}
.quote__slide > * {
opacity: 0;
transform: translateY(var(--space-6));
transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}
.quote__slide[data-leaving] > * { transform: translateY(calc(-1 * var(--space-5))); }
.quote__slide > :nth-child(2) { --i: 1; }
.quote__slide > :nth-child(3) { --i: 2; }
.quote__slide[data-active] { visibility: visible; transition: visibility 0s; }
.quote__slide[data-active] > * {
opacity: 1;
transform: none;
transition-duration: var(--dur-rise);
transition-delay: calc(var(--dur-fast) + var(--i, 0) * var(--stagger-rise));
}
.quote__portraits { overflow: hidden; }
.quote__portrait {
opacity: 0;
visibility: hidden;
/* The old portrait stays put until the new one has covered it. */
transition: opacity 0s linear var(--dur-hero), visibility 0s linear var(--dur-hero);
}
.quote__portrait :global(img) {
transform: scale(1.06);
transition: transform 0s linear var(--dur-hero);
}
.quote__portrait[data-active] {
z-index: 1;
opacity: 1;
visibility: visible;
transition: opacity var(--dur-rise) var(--ease), visibility 0s;
}
.quote__portrait[data-active] :global(img) {
transform: none;
transition: transform var(--dur-hero) var(--ease);
}
.quote__source { text-decoration: none; }
.quote__source:hover { text-decoration: underline; }
.quote__text { text-wrap: balance; max-width: 16em; } .quote__text { text-wrap: balance; max-width: 16em; }
.quote__name { font-weight: var(--weight-medium); } .quote__name { font-weight: var(--weight-medium); }
/*
* Controls: the progress bar runs along the bottom edge of the text panel, one segment per
* statement, filling while it is shown. Each segment is a button with a taller hit area above it.
*/
.quote__panel { position: relative; }
.quote__controls {
position: absolute;
inset: auto 0 0 0;
z-index: 2; /* above the portrait, which on narrow screens starts right below the bar */
display: flex;
gap: var(--space-1);
}
.quote__controls[hidden] { display: none; }
.quote__pick {
display: flex;
flex-direction: column;
justify-content: end;
flex: 1 1 0;
min-width: 0;
height: var(--space-6);
padding: 0;
background: none;
border: 0;
cursor: pointer;
}
.quote__pick:focus-visible { outline-offset: calc(-1 * var(--focus-width)); }
.quote__pick:hover .quote__track { background: var(--ink-muted); }
.quote__track {
display: block;
height: calc(2 * var(--hairline));
transition: background-color var(--dur-fast) var(--ease);
background: var(--line);
overflow: hidden;
}
.quote__fill {
display: block;
height: 100%;
background: var(--ink);
/* A full-width bar slid in from the left, not a scaled one: translation stays on the
compositor and moves by sub-pixels, scaling from zero re-rasterises and steps. */
transform: translateX(-100%);
will-change: transform;
}
.quote__pick[aria-current] .quote__fill { transform: none; }
.quote[data-playing] .quote__pick[aria-current] .quote__fill {
animation: quote-fill var(--quote-hold) linear both;
}
.quote[data-playing]:is([data-held], [data-away]) .quote__pick[aria-current] .quote__fill { animation-play-state: paused; }
@keyframes quote-fill { from { transform: translateX(-100%); } to { transform: none; } }
:global(.quote__picture) { display: block; width: 100%; height: 100%; } :global(.quote__picture) { display: block; width: 100%; height: 100%; }
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; } .quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
@media (min-width: 64rem) { @media (min-width: 64rem) {
.quote__figure { grid-template-columns: 11fr 9fr; } .quote__frame { grid-template-columns: 11fr 9fr; }
.quote__panel { padding: var(--space-8) var(--space-8); } .quote__panel { padding: var(--space-8) var(--space-8); }
.quote__img, :global(.quote__img) { aspect-ratio: auto; } /* The image's own ratio once loaded, and the same square reserved before, so the lazy
portrait does not grow the section when it arrives. Both portraits are square. */
.quote__img, :global(.quote__img) { aspect-ratio: auto 1 / 1; }
}
@media (prefers-reduced-motion: reduce) {
.quote__slide, .quote__slide > *, .quote__portrait, .quote__portrait :global(img) {
transition: none !important;
transform: none !important;
}
} }
</style> </style>
<script>
/**
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while
* keyboard 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 frame = root.querySelector<HTMLElement>('[data-frame]')!;
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); }));
// A click anywhere else on the card moves on to the next statement and (re)starts the rotation,
// so the new statement gets its full hold. Links and the picks keep their own behaviour, and
// selecting the quote to copy it does not count.
frame.addEventListener('click', (e) => {
if ((e.target as Element).closest('a, button')) return;
if (window.getSelection()?.toString()) return;
show(index + 1);
setPlaying(!reduced);
});
const hold = (on: boolean) => root.toggleAttribute('data-held', on);
root.addEventListener('focusin', () => hold(true));
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
// 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;
root.setAttribute('data-ready', '');
setPlaying(!reduced);
}
</script>
+79 -15
View File
@@ -2,13 +2,17 @@
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import fiveG from '../../assets/images/sector-5g.jpg'; import fiveG from '../../assets/images/sector-5g.jpg';
import substation from '../../assets/images/sector-substation.jpg'; import substation from '../../assets/images/sector-substation.jpg';
import controlRoom from '../../assets/images/sector-ot.jpg';
import products from '../../assets/images/sector-device-line.jpg';
import datacentre from '../../assets/images/sector-datacentre.jpg'; import datacentre from '../../assets/images/sector-datacentre.jpg';
import { t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
const images = [fiveG, substation, datacentre]; const images = [fiveG, substation, controlRoom, products, datacentre];
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id: `sector-${i + 1}` })); /* Where each picture keeps its subject when cropped. */
const focus = ['center', 'center', 'center', 'center', 'center'];
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], focus: focus[i], id: `sector-${i + 1}` }));
--- ---
<section class="section sectors" id="sectors" aria-labelledby="sectors-title"> <section class="section sectors" id="sectors" aria-labelledby="sectors-title">
<div class="container sectors__grid" data-sectors> <div class="container sectors__grid" data-sectors>
@@ -28,7 +32,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
tabindex={i === 0 ? undefined : -1} tabindex={i === 0 ? undefined : -1}
>{item.title}</button> >{item.title}</button>
</h3> </h3>
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0"> <div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0" data-open={i === 0 ? '' : undefined}>
<div class="sectors__panel-inner"> <div class="sectors__panel-inner">
<p class="muted">{item.body}</p> <p class="muted">{item.body}</p>
<Picture <Picture
@@ -36,11 +40,12 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
alt={item.imageAlt} alt={item.imageAlt}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[480, 768, 1024, 1440]} widths={[480, 768, 1024, 1440, 1920]}
sizes="(min-width: 64rem) 50vw, 100vw" sizes="(min-width: 64rem) 60vw, 100vw"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
class="sectors__img img-doc" class="sectors__img img-doc"
style={`object-position: ${item.focus}`}
pictureAttributes={{ class: 'sectors__picture' }} pictureAttributes={{ class: 'sectors__picture' }}
/> />
</div> </div>
@@ -56,11 +61,12 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
alt="" alt=""
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[480, 768, 1024, 1440]} widths={[480, 768, 1024, 1440, 1920]}
sizes="(min-width: 64rem) 50vw, 100vw" sizes="(min-width: 64rem) 60vw, 100vw"
loading="lazy" loading="lazy"
decoding="async" decoding="async"
class="sectors__img img-doc" class="sectors__img img-doc"
style={`object-position: ${item.focus}`}
pictureAttributes={{ class: 'sectors__picture sectors__picture--stage', 'data-stage-item': '', 'data-active': i === 0 ? '' : undefined }} pictureAttributes={{ class: 'sectors__picture sectors__picture--stage', 'data-stage-item': '', 'data-active': i === 0 ? '' : undefined }}
/> />
))} ))}
@@ -69,6 +75,7 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
</section> </section>
<style> <style>
.sectors { border-top: var(--hairline) solid var(--line); }
.sectors__grid { .sectors__grid {
display: grid; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
@@ -81,11 +88,34 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
flex-direction: column; flex-direction: column;
gap: var(--space-8); gap: var(--space-8);
} }
.sectors__tabs { margin-top: auto; } /* The tabs are rows between hairlines. The selected row's rule turns ink: that is the marker. */
.sectors__tab + .sectors__tab { margin-top: var(--space-5); } .sectors__tabs {
margin-top: auto;
border-bottom: var(--hairline) solid var(--line);
}
.sectors__tab {
position: relative;
border-top: var(--hairline) solid var(--line);
}
.sectors__tab::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: calc(-1 * var(--hairline));
height: var(--hairline);
background: var(--ink);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dur-slow) var(--ease);
}
.sectors__tab:has([aria-selected='true'])::before { transform: none; }
.sectors__heading { font-size: inherit; font-weight: inherit; } .sectors__heading { font-size: inherit; font-weight: inherit; }
/* The whole row is the button, so the target is as wide as the column. */
.sectors__button { .sectors__button {
display: block; display: block;
width: 100%;
padding-block: var(--space-5) var(--space-3);
text-align: left; text-align: left;
font-size: var(--step-h3); font-size: var(--step-h3);
line-height: var(--leading-h3); line-height: var(--leading-h3);
@@ -96,7 +126,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
} }
.sectors__button[aria-selected='false'] { color: var(--ink-muted); } .sectors__button[aria-selected='false'] { color: var(--ink-muted); }
.sectors__button[aria-selected='false']:hover { color: var(--ink); } .sectors__button[aria-selected='false']:hover { color: var(--ink); }
.sectors__panel-inner { display: grid; gap: var(--space-5); padding-top: var(--space-3); } .sectors__panel-inner { display: grid; gap: var(--space-5); }
/* The button keeps a small step below its title, so an open body sits close under it and the
focus ring never crosses the text. The rest of the row padding is the panel's margin: below
the body when open, in place of it when closed, so every row is symmetric. */
.sectors__panel { margin-bottom: var(--space-5); }
.sectors__panel p { max-width: 28em; } .sectors__panel p { max-width: 28em; }
:global(.sectors__picture) { display: block; width: 100%; } :global(.sectors__picture) { display: block; width: 100%; }
.sectors__img, :global(.sectors__img) { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; } .sectors__img, :global(.sectors__img) { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; }
@@ -104,24 +138,50 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
/* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */ /* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */
.sectors__media { display: none; } .sectors__media { display: none; }
/* With scripting, one panel at a time; without, everything is readable. */ /* With scripting, one panel at a time; without, everything is readable. The first panel is
open in the markup, so nothing expands on load and the page below it does not move. */
:global(html.js) .sectors__panel { :global(html.js) .sectors__panel {
display: grid; display: grid;
grid-template-rows: 0fr; grid-template-rows: 0fr;
overflow: hidden; overflow: hidden;
transition: grid-template-rows var(--dur-slow) var(--ease); transition:
grid-template-rows var(--dur-slow) var(--ease),
margin var(--dur-slow) var(--ease);
} }
:global(html.js) .sectors__panel-inner { min-height: 0; } :global(html.js) .sectors__panel-inner { min-height: 0; }
:global(html.js) .sectors__panel[data-open] { grid-template-rows: 1fr; } :global(html.js) .sectors__panel[data-open] { grid-template-rows: 1fr; }
/* A closed panel is hidden, not only collapsed, so it does not take focus while invisible.
Visibility switches at the end of the collapse and at the start of the expand. */
:global(html.js) .sectors__panel:not([data-open]) {
margin-bottom: calc(var(--space-5) - var(--space-3));
visibility: hidden;
transition:
grid-template-rows var(--dur-slow) var(--ease),
margin var(--dur-slow) var(--ease),
visibility 0s linear var(--dur-slow);
}
@media (min-width: 64rem) { @media (min-width: 64rem) {
.sectors__text { grid-column: 1 / span 5; } .sectors__text { grid-column: 1 / span 5; }
/* The image runs past the container to the right edge of the window: the page gutter, plus
half of whatever the window is wider than the container. The section clips, so a classic
scrollbar (counted in 100vw) cannot cause a horizontal scroll. */
.sectors { overflow-x: clip; }
/* The image box is at least 5:4 (never taller than 45rem), and stretches with the row when
the list beside it is taller, so its top and bottom always meet the heading and the last rule. */
.sectors__media { .sectors__media {
grid-column: 7 / span 6; grid-column: 7 / span 6;
display: grid; display: block;
position: relative;
margin-right: calc(-1 * (max(var(--gutter), var(--safe-right)) + max(0px, (100vw - var(--container)) / 2)));
} }
/* The minimum: a spacer as tall as 5:4 of the width, capped at 45rem. A grid item stretches
to its row, so a taller list beside it makes the box taller; the pictures fill the box. */
.sectors__media::before { content: ''; display: block; padding-top: min(80%, 45rem); }
.sectors__media :global(.sectors__img) { height: 100%; aspect-ratio: auto; }
.sectors__media :global(.sectors__picture--stage) { .sectors__media :global(.sectors__picture--stage) {
grid-area: 1 / 1; position: absolute;
inset: 0;
opacity: 0; opacity: 0;
transition: opacity var(--dur-slow) var(--ease); transition: opacity var(--dur-slow) var(--ease);
} }
@@ -131,7 +191,11 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.sectors__panel, .sectors__media :global(.sectors__picture--stage) { transition: none; } .sectors__panel,
:global(html.js) .sectors__panel,
:global(html.js) .sectors__panel:not([data-open]),
.sectors__tab::before,
.sectors__media :global(.sectors__picture--stage) { transition: none; }
} }
</style> </style>
-70
View File
@@ -1,70 +0,0 @@
---
import GridObject from './GridObject.astro';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
---
<section class="section--tight stages" aria-labelledby="stages-title">
<div class="container">
<h2 id="stages-title" class="eyebrow stages__eyebrow">{ui.stages.eyebrow}</h2>
<ol class="stages__list" role="list">
{ui.stages.items.map((item, i) => (
<li class="stages__item" id={`capability-${i + 1}`}>
<h3 class="stages__title">{item.title}</h3>
<GridObject kind={objects[i]} class="stages__object" />
<p class="muted stages__body">{item.body}</p>
</li>
))}
</ol>
</div>
</section>
<style>
.stages__eyebrow { margin-bottom: var(--space-6); }
.stages__list {
display: grid;
gap: var(--gap);
}
.stages__item {
display: grid;
grid-template-columns: minmax(0, 1fr);
/* Title, then the drawing in the row that takes the slack, then the body at the bottom. */
grid-template-rows: auto 1fr auto;
gap: var(--space-6);
padding: var(--space-6) var(--space-6) var(--space-7);
background: var(--paper-deep);
scroll-margin-top: calc(var(--header-height) + var(--space-5));
}
.stages__title {
font-size: var(--step-title);
line-height: var(--leading-title);
letter-spacing: var(--track-h2);
font-weight: var(--weight-regular);
/* Two lines reserved, so objects and bodies line up across the row. */
min-height: calc(2 * var(--leading-title) * 1em);
hyphens: auto;
}
.stages__item :global(.stages__object) {
width: 100%;
max-width: 16rem;
justify-self: center;
align-self: center;
}
/* Hovering a card lets its grid recede, so the shape that carries the idea stands alone. */
@media (hover: hover) {
.stages__item:hover {
--object-scale: 1.03;
--object-grid: 0.26;
}
}
.stages__body { max-width: 24em; }
@media (min-width: 48rem) {
.stages__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 64rem) {
.stages__list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.stages__item :global(.stages__object) { max-width: 13rem; }
}
</style>
+307
View File
@@ -0,0 +1,307 @@
---
import data from '../../data/research.json';
import { t, type Lang } from '../../i18n/ui';
import type { ChladniParams } from '../../scripts/chladni';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const r = ui.team.research;
/* Papers from both founders' Google Scholar profiles, deduplicated (src/data/research.json).
Rendered most cited first, so the page reads right without script; the script below re-sorts,
filters and folds the list. Entries marked display: false stay in the data only. */
const papers = data.papers.filter((p) => p.display && p.href);
const categories = data.categories.filter((c) => papers.some((p) => p.categories.includes(c)));
const countIn = (c: string) => papers.filter((p) => p.categories.includes(c)).length;
const INITIAL = 9;
const labels = { showAll: r.showAll(papers.length), showAllTpl: r.showAll(999).replace('999', '{n}'), showFewer: r.showFewer, count: r.count(111, 999).replace('111', '{shown}').replace('999', '{total}') };
/* Every paper carries its own figure (Chladni parameters for src/scripts/chladni.ts) and colour
field (three tokens and their positions) in research.json, generated per paper by
scripts/research-figures.py. They travel with the paper, so sorting and filtering never move them. */
const figure = (paper: (typeof papers)[number]) => ({ quality: 121, ...paper.figure }) as ChladniParams;
const fieldStyle = (paper: (typeof papers)[number]) =>
paper.field.colors.map((c, k) => `--c${k + 1}: var(--${c}); --ax${k + 1}: ${paper.field.positions[k][0]}%; --ay${k + 1}: ${paper.field.positions[k][1]}%;`).join(' ');
---
<section class="section research" aria-labelledby="research-title">
<div class="container">
<div class="grid research__head">
<h2 id="research-title" class="h2 research__title">
{ui.team.research.title.map((line) => <span class="research__line">{line}</span>)}
</h2>
<p class="research__intro muted">{ui.team.research.intro}</p>
</div>
<div class="research__controls" data-research-controls hidden>
<fieldset class="choice-group">
<legend class="choice-group__legend">{r.sortLabel}</legend>
<div class="choices">
{(['cites', 'newest', 'oldest'] as const).map((key, i) => (
<label class="choice">
<input type="radio" name="research-sort" value={key} checked={i === 0} />
<span>{r.sort[key]}</span>
</label>
))}
</div>
</fieldset>
<fieldset class="choice-group">
<legend class="choice-group__legend">{r.filterLabel}</legend>
<div class="choices">
<label class="choice">
<input type="radio" name="research-category" value="" checked />
<span>{r.all} <span class="choice__n">{papers.length}</span></span>
</label>
{categories.map((c) => (
<label class="choice">
<input type="radio" name="research-category" value={c} />
<span>{r.categories[c] ?? c} <span class="choice__n">{countIn(c)}</span></span>
</label>
))}
</div>
</fieldset>
</div>
<ul class="research__list" role="list" data-research-list data-initial={INITIAL}>
{papers.map((paper, i) => (
<li
class="research__item"
data-cites={paper.cites}
data-date={paper.date ?? String(paper.year)}
data-order={i}
data-categories={paper.categories.join('|')}
>
<a class="paper" href={paper.href} rel="noopener" lang="en">
<div class="paper__field" style={fieldStyle(paper)}>
<canvas class="paper__lines" aria-hidden="true" data-chladni={JSON.stringify(figure(paper))}></canvas>
<span class="paper__year">{paper.year}</span>
</div>
<div class="paper__body">
<h3 class="paper__title">{paper.title}</h3>
<p class="paper__venue small muted">{paper.venue}</p>
<div class="paper__meta small">
<span class="muted" lang={lang}>{paper.categories.map((c) => r.categories[c] ?? c).join(', ')}</span>
</div>
</div>
</a>
</li>
))}
</ul>
<div class="research__foot">
<p class="small muted" aria-live="polite" data-research-count></p>
<button class="research__more" type="button" data-research-more hidden data-labels={JSON.stringify(labels)}>{labels.showAll}</button>
</div>
</div>
</section>
<style>
/* Registered so the field positions can transition on hover. */
@property --x1 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y1 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --x2 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y2 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --x3 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y3 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
.research__line { display: block; }
.research__title { grid-column: 1 / -1; }
.research__intro { grid-column: 1 / -1; max-width: var(--measure); }
@media (min-width: 48rem) {
.research__title { grid-column: 1 / span 7; }
.research__intro { grid-column: 9 / span 4; align-self: end; }
}
.research__list {
margin-top: var(--space-8);
display: grid;
gap: var(--gap);
}
@media (min-width: 40rem) { .research__list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .research__list { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.paper {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
border: var(--hairline) solid var(--line);
text-decoration: none;
transition: border-color var(--dur-fast) var(--ease);
}
.paper:hover { border-color: var(--ink-muted); }
.paper:hover .paper__title {
text-decoration: underline;
text-decoration-thickness: var(--hairline);
text-underline-offset: 0.14em;
}
.paper__field {
--x1: var(--ax1); --y1: var(--ay1);
--x2: var(--ax2); --y2: var(--ay2);
--x3: var(--ax3); --y3: var(--ay3);
position: relative;
aspect-ratio: 316 / 170;
overflow: hidden;
background-color: var(--night);
background-image:
radial-gradient(80% 110% at var(--x1) var(--y1), var(--c1), transparent 70%),
radial-gradient(70% 100% at var(--x2) var(--y2), var(--c2), transparent 68%),
radial-gradient(95% 130% at var(--x3) var(--y3), var(--c3), transparent 72%);
transition:
--x1 var(--dur-slow) var(--ease), --y1 var(--dur-slow) var(--ease),
--x2 var(--dur-slow) var(--ease), --y2 var(--dur-slow) var(--ease),
--x3 var(--dur-slow) var(--ease), --y3 var(--dur-slow) var(--ease);
}
/* Hover answers the pointer: the fields drift a little, nothing lifts or glows. */
.paper:hover .paper__field {
--x1: calc(var(--ax1) + 7%); --y1: calc(var(--ay1) - 6%);
--x2: calc(var(--ax2) - 6%); --y2: calc(var(--ay2) + 7%);
--x3: calc(var(--ax3) + 5%); --y3: calc(var(--ay3) + 5%);
}
/* Film grain over the colour. */
.paper__field::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
opacity: 0.6;
}
/* The Chladni lines: a 3:1 canvas centred on the field and cropped by it, so the figure keeps
its proportions at every card width. Stroke colour comes from `color`. */
.paper__lines {
position: absolute;
top: 0;
left: 50%;
height: 100%;
aspect-ratio: 3 / 1;
transform: translateX(-50%);
color: color-mix(in srgb, var(--paper) 72%, transparent);
pointer-events: none;
}
.paper__year {
position: absolute;
top: var(--space-3);
left: var(--space-3);
z-index: 1;
padding: var(--space-1) var(--space-2);
background: var(--paper);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
line-height: 1;
}
.paper__body {
display: grid;
align-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
}
.paper__title {
font-size: var(--step-body);
font-weight: var(--weight-medium);
line-height: var(--leading-small);
letter-spacing: 0;
}
/* Controls: segmented rows (.choice-group, base.css). */
.research__controls {
margin-top: var(--space-7);
display: grid;
gap: var(--space-4);
}
.research__list { margin-top: var(--space-6); scroll-margin-top: calc(var(--header-offset) + var(--space-5)); }
.research__item[hidden] { display: none; }
.paper__meta {
padding-top: var(--space-3);
border-top: var(--hairline) solid var(--line);
}
.research__foot {
margin-top: var(--space-6);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
}
.research__more {
padding: var(--space-3) var(--space-5);
border: var(--hairline) solid var(--ink);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
text-transform: uppercase;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.research__more:hover { background: var(--ink); color: var(--paper); }
</style>
<script>
import { mountChladni, type ChladniParams } from '../../scripts/chladni';
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Figures draw when their card comes near the viewport: 39 canvases, most never seen. */
const lazy = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const canvas = entry.target as HTMLCanvasElement;
lazy.unobserve(canvas);
const card = canvas.closest<HTMLElement>('.paper');
if (card) mountChladni(canvas, card, JSON.parse(canvas.dataset.chladni!) as ChladniParams, reduce);
}
}, { rootMargin: '300px 0px' });
document.querySelectorAll<HTMLCanvasElement>('[data-chladni]').forEach((c) => lazy.observe(c));
/* Sort, filter, fold. The server renders every card most cited first; this only re-orders and
hides list items, so links, figures and focus stay intact. */
const list = document.querySelector<HTMLElement>('[data-research-list]');
const controls = document.querySelector<HTMLElement>('[data-research-controls]');
const more = document.querySelector<HTMLButtonElement>('[data-research-more]');
const count = document.querySelector<HTMLElement>('[data-research-count]');
if (list && controls && more && count) {
const items = [...list.querySelectorAll<HTMLElement>('.research__item')];
const initial = Number(list.dataset.initial) || 9;
const labels = JSON.parse(more.dataset.labels!) as { showAllTpl: string; showFewer: string; count: string };
let expanded = false;
controls.hidden = false;
const value = (name: string) => controls.querySelector<HTMLInputElement>(`input[name="${name}"]:checked`)?.value ?? '';
const compare: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
cites: (a, b) => Number(b.dataset.cites) - Number(a.dataset.cites) || Number(a.dataset.order) - Number(b.dataset.order),
newest: (a, b) => (b.dataset.date ?? '').localeCompare(a.dataset.date ?? '') || Number(b.dataset.cites) - Number(a.dataset.cites),
oldest: (a, b) => (a.dataset.date ?? '').localeCompare(b.dataset.date ?? '') || Number(b.dataset.cites) - Number(a.dataset.cites),
};
const render = () => {
const sort = value('research-sort') || 'cites';
const category = value('research-category');
const matching = items.filter((li) => !category || li.dataset.categories!.split('|').includes(category));
matching.sort(compare[sort]);
const visible = expanded ? matching : matching.slice(0, initial);
for (const li of items) li.hidden = true;
for (const li of matching) { list.append(li); }
for (const li of visible) li.hidden = false;
count.textContent = labels.count.replace('{shown}', String(visible.length)).replace('{total}', String(matching.length));
more.hidden = matching.length <= initial;
more.textContent = expanded ? labels.showFewer : labels.showAllTpl.replace('{n}', String(matching.length));
more.setAttribute('aria-expanded', String(expanded));
};
controls.addEventListener('change', () => { expanded = false; render(); });
more.addEventListener('click', () => {
expanded = !expanded;
render();
if (!expanded) list.scrollIntoView({ block: 'start' });
});
render();
}
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
--- ---
import GridObject from '../home/GridObject.astro'; import GridObject from './GridObject.astro';
import { t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
+96
View File
@@ -0,0 +1,96 @@
---
import Verticals from './Verticals.astro';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const s = ui.verifier.soon;
/* The step that is current; earlier ones read as passed, later ones as open. */
const now = 0;
---
<section class="section track" aria-labelledby="track-title">
<div class="container grid">
<div class="track__head">
<h2 id="track-title" class="h2">{s.track.title}</h2>
<p class="muted">{s.track.body}</p>
</div>
<ol class="track__steps" role="list">
{s.track.steps.map((step, i) => (
<li class="track__step" data-state={i < now ? 'past' : i === now ? 'now' : 'next'} aria-current={i === now ? 'step' : undefined}>
<span class="track__node" aria-hidden="true" />
<p class="track__state">{step.state}</p>
<h3 class="h3 track__name">{step.name}</h3>
<p class="track__body muted">{step.body}</p>
</li>
))}
</ol>
</div>
</section>
<Verticals lang={lang} />
<style>
/* Release path: one hairline, one node per phase. */
.track__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
.track__head p { max-width: 24em; }
.track__steps { grid-column: 1 / -1; display: grid; gap: var(--space-7); }
.track__step {
position: relative;
display: grid;
gap: var(--space-2);
align-content: start;
padding-left: var(--space-7);
}
/* Vertical rail on small screens. */
.track__step::before {
content: '';
position: absolute;
left: calc(0.5rem - var(--hairline) / 2);
top: 1.25rem;
bottom: calc(-1 * var(--space-7));
border-left: var(--hairline) solid var(--ink-muted);
}
.track__step:last-child::before { bottom: 0; border-left-style: dashed; }
.track__step[data-state='next']::before { border-left-style: dashed; border-color: var(--line); }
.track__node {
position: absolute;
left: 0;
top: 0.2rem;
width: 1rem;
height: 1rem;
border: var(--hairline) solid var(--ink);
border-radius: 50%;
background: var(--paper);
}
.track__step[data-state='now'] .track__node { outline: var(--hairline) solid var(--ink-muted); outline-offset: 0.35rem; }
.track__step[data-state='now'] .track__node::after {
content: '';
position: absolute;
inset: 0.2rem;
border-radius: 50%;
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); }
.track__step[data-state='next'] .track__name { color: var(--ink-muted); }
.track__body { max-width: 22em; }
@media (min-width: 48rem) {
.track__head { grid-column: 1 / span 4; }
}
/* Wide: the rail turns horizontal and runs across all three phases. */
@media (min-width: 64rem) {
.track__steps { grid-column: 5 / span 8; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap); }
.track__step { padding-left: 0; padding-top: var(--space-7); padding-right: var(--space-4); }
.track__step::before {
left: 1.25rem;
right: calc(-1 * var(--gap));
top: calc(0.5rem - var(--hairline) / 2);
bottom: auto;
border-left: 0;
border-top: var(--hairline) solid var(--ink-muted);
}
.track__step:last-child::before { right: 0; border-top-style: dashed; }
.track__step[data-state='next']::before { border-top-style: dashed; border-color: var(--ink-muted); }
.track__node { top: 0; }
}
</style>
@@ -3,12 +3,10 @@
* Grid objects: wireframe solids drawn as technical line drawings. * Grid objects: wireframe solids drawn as technical line drawings.
* Single stroke in currentColor, even width, no fills. The geometry is computed * Single stroke in currentColor, even width, no fills. The geometry is computed
* at build time so the markup stays small and the stroke stays exact. * at build time so the markup stays small and the stroke stays exact.
* Kinds: sphere (a model seen from every side), cube (a built structure), * Kinds: steps (a ranked row of columns), nested (a structure inside its own walls),
* plane (one element lifted out for inspection), wave (a signal over time),
* steps (a ranked row of columns), nested (a structure inside its own walls),
* stack (layers kept one on top of the other). * stack (layers kept one on top of the other).
*/ */
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave' | 'steps' | 'nested' | 'stack'; class?: string } interface Props { kind: 'steps' | 'nested' | 'stack'; class?: string }
const { kind, class: className } = Astro.props; const { kind, class: className } = Astro.props;
const S = 400; const S = 400;
@@ -60,78 +58,6 @@ function plane(n: number, u: number, y: number, cy: number, cx = ISO.cx): string
const base: string[] = []; const base: string[] = [];
const accent: string[] = []; const accent: string[] = [];
if (kind === 'sphere') {
const R = 150, tilt = (22 * Math.PI) / 180, N = 48;
const p3 = (lat: number, lon: number): [number, number] => {
const x = R * Math.cos(lat) * Math.sin(lon);
const y = R * Math.sin(lat);
const z = R * Math.cos(lat) * Math.cos(lon);
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
return [ISO.cx + x, ISO.cy - y2];
};
for (let d = -80; d <= 80; d += 20) {
const lat = (d * Math.PI) / 180;
const pts: [number, number][] = [];
for (let i = 0; i <= N; i++) pts.push(p3(lat, (i / N) * 2 * Math.PI));
(d === 0 ? accent : base).push(poly(pts));
}
for (let m = 0; m < 8; m++) {
const lon = (m * Math.PI) / 8;
const pts: [number, number][] = [];
for (let i = 0; i <= N; i++) pts.push(p3((i / N) * 2 * Math.PI - Math.PI, lon));
(m === 0 ? accent : base).push(poly(pts));
}
accent.push(`circle:${R}`);
track(ISO.cx - R, ISO.cy - R);
track(ISO.cx + R, ISO.cy + R);
}
if (kind === 'cube') {
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
for (let i = 0; i <= n; i++) {
const t = i * u;
/* The first and last line of each face are the cube's own edges. */
const target = i === 0 || i === n ? accent : base;
target.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
target.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
target.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
target.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
target.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
target.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
}
}
if (kind === 'plane') {
const n = 6, u = 34, L = n * u, cy = ISO.cy + 20, h = 96, tx = 2, tz = 2;
for (let i = 0; i <= n; i++) {
const t = i * u;
base.push(poly([iso(t, 0, 0, cy), iso(t, 0, L, cy)]));
base.push(poly([iso(0, 0, t, cy), iso(L, 0, t, cy)]));
}
/* The tile that has been lifted out for inspection. */
const c: [number, number][] = [[tx, tz], [tx + 1, tz], [tx + 1, tz + 1], [tx, tz + 1]];
const top = c.map(([x, z]) => iso(x * u, h, z * u, cy));
accent.push(poly([...top, top[0]]));
c.forEach(([x, z], i) => accent.push(poly([iso(x * u, 0, z * u, cy), top[i]])));
}
if (kind === 'wave') {
const nx = 12, nz = 8, u = 26, cy = ISO.cy + 10, A = 26;
const yAt = (x: number, z: number) => A * Math.sin((x / nx) * Math.PI * 2 - 0.6) * Math.cos((z / nz) * Math.PI * 0.9);
const P = (x: number, z: number) => iso((x - nx / 2) * u, yAt(x, z), (z - nz / 2) * u, cy);
/* The lines along x carry the wave itself; the cross lines are the grid it is read against. */
for (let z = 0; z <= nz; z++) {
const pts: [number, number][] = [];
for (let x = 0; x <= nx; x++) pts.push(P(x, z));
accent.push(poly(pts));
}
for (let x = 0; x <= nx; x++) {
const pts: [number, number][] = [];
for (let z = 0; z <= nz; z++) pts.push(P(x, z));
base.push(poly(pts));
}
}
if (kind === 'steps') { if (kind === 'steps') {
/* Five columns in a row, tallest first: a ranked list seen as a skyline. */ /* Five columns in a row, tallest first: a ranked list seen as a skyline. */
const heights = [168, 128, 96, 68, 44], w = 40, gap = 14, depth = 40, cy = ISO.cy + 78; const heights = [168, 128, 96, 68, 44], w = 40, gap = 14, depth = 40, cy = ISO.cy + 78;
@@ -167,7 +93,7 @@ if (kind === 'stack') {
corners.forEach(([x, z]) => base.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)]))); corners.forEach(([x, z]) => base.push(poly([iso(x, 0, z, cy), iso(x, (layers - 1) * gapY, z, cy)])));
} }
/* A square box centred on the drawing: wide drawings still fill the width, and every object /* A square box centred on the drawing: wide drawings still fill the width, and every object
keeps the same footprint, so the four cards of a row stay even. The padding covers the keeps the same footprint, so the cards of a row stay even. The padding covers the
stroke, which sits half outside the geometry. */ stroke, which sits half outside the geometry. */
const PAD = 8; const PAD = 8;
const width = maxX - minX; const width = maxX - minX;
@@ -178,14 +104,10 @@ const viewBox = [minX + width / 2 - side / 2, minY + height / 2 - side / 2, side
<svg class:list={['grid-object', className]} viewBox={viewBox} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg"> <svg class:list={['grid-object', className]} viewBox={viewBox} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<g class="grid-object__lines" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"> <g class="grid-object__lines" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
<g class="grid-object__base"> <g class="grid-object__base">
{base.map((l) => l.startsWith('circle:') {base.map((l) => <polyline points={l} />)}
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
: <polyline points={l} />)}
</g> </g>
<g class="grid-object__accent"> <g class="grid-object__accent">
{accent.map((l) => l.startsWith('circle:') {accent.map((l) => <polyline points={l} />)}
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
: <polyline points={l} />)}
</g> </g>
</g> </g>
</svg> </svg>
-1
View File
@@ -166,7 +166,6 @@ const groups = v.groups.map((g, i) => ({ title: g.title, body: g.body, desc: g.d
right: calc(-1 * (var(--gap) + 200%)); right: calc(-1 * (var(--gap) + 200%));
width: 200%; width: 200%;
padding: var(--space-6); padding: var(--space-6);
border: var(--hairline) solid var(--line);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transition: opacity var(--dur-slow) var(--ease); transition: opacity var(--dur-slow) var(--ease);
+12 -2
View File
@@ -41,6 +41,12 @@ layout.edges.forEach((e) => seen(e.group));
layout.texts?.forEach((t) => seen(t.group)); layout.texts?.forEach((t) => seen(t.group));
const freeEdges = layout.edges.filter((e) => !e.group).map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: i * 150 })); const freeEdges = layout.edges.filter((e) => !e.group).map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: i * 150 }));
/* A group is lit by its own hover and by the hover of any group that names it in `layout.lit`. */
const litBy = (key: string) => [key, ...Object.entries(layout.lit ?? {}).filter(([, ks]) => ks.includes(key)).map(([k]) => k)].join(' ');
const hoverCss = Object.keys(layout.lit ?? {})
.map((k) => `.schematic:has(.schematic__group[data-group="${k}"]:hover) .schematic__group[data-lit~="${k}"] { --dim: 1; }`)
.join('');
const groups = order.map((g, gi) => { const groups = order.map((g, gi) => {
const start = 320 + gi * 200; const start = 320 + gi * 200;
const nodes = layout.nodes.filter((n) => n.group === g); const nodes = layout.nodes.filter((n) => n.group === g);
@@ -48,6 +54,7 @@ const groups = order.map((g, gi) => {
const texts = (layout.texts ?? []).filter((t) => t.group === g); const texts = (layout.texts ?? []).filter((t) => t.group === g);
return { return {
key: g, key: g,
lit: litBy(g),
rings: nodes.filter((n) => n.ring).map((n) => ({ x: n.x, y: n.y, r: n.ring!, d: start })), rings: nodes.filter((n) => n.ring).map((n) => ({ x: n.x, y: n.y, r: n.ring!, d: start })),
edges: edges.map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: start + 80 + i * 45 })), edges: edges.map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: start + 80 + i * 45 })),
dots: nodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: start + 120 + i * 45 })), dots: nodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: start + 120 + i * 45 })),
@@ -82,7 +89,7 @@ const fontSub = layout.fontSub ?? 15;
</g> </g>
{groups.map((g) => ( {groups.map((g) => (
<g class="schematic__group" data-group={g.key}> <g class="schematic__group" data-group={g.key} data-lit={g.lit}>
<g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke"> <g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
{g.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)} {g.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)}
{g.edges.map((f) => ( {g.edges.map((f) => (
@@ -154,8 +161,11 @@ const fontSub = layout.fontSub ?? 15;
/* Hover singles out one node and what belongs to it. */ /* Hover singles out one node and what belongs to it. */
@media (hover: hover) and (min-width: 64rem) { @media (hover: hover) and (min-width: 64rem) {
.schematic__group, .schematic__edges, .schematic__free { transition: opacity var(--dur) var(--ease); } .schematic__group, .schematic__edges, .schematic__free { transition: opacity var(--dur) var(--ease); }
.schematic:has(.schematic__group:hover) .schematic__group:not(:hover) { opacity: 0.28; } .schematic:has(.schematic__group:hover) .schematic__group:not(:hover) { opacity: var(--dim, 0.28); }
.schematic:has(.schematic__group:hover) .schematic__edges, .schematic:has(.schematic__group:hover) .schematic__edges,
.schematic:has(.schematic__group:hover) .schematic__free { opacity: 0.45; } .schematic:has(.schematic__group:hover) .schematic__free { opacity: 0.45; }
} }
</style> </style>
<!-- Cross-group hovers from `layout.lit`: written out here because the group keys are data. -->
{hoverCss && <style is:inline set:html={`@media (hover: hover) and (min-width: 64rem) { ${hoverCss} }`} />}
+103
View File
@@ -0,0 +1,103 @@
---
/**
* The seal on the sign-up result pages: a target drawn as a technical line drawing. On load the
* corner brackets close in and the result is drawn in the centre: a check when the link verified,
* a cross when it did not. Then it stays still.
* Geometry is computed at build time; the motion is CSS only and off under reduced motion.
*/
interface Props { state: 'confirmed' | 'error'; label: string }
const { state, label } = Astro.props;
const C = 200, R = 184, N = 60;
const f = (n: number) => Math.round(n * 10) / 10;
const at = (r: number, a: number): [number, number] => [C + r * Math.sin(a), C - r * Math.cos(a)];
/* Ticks clockwise from twelve o'clock; every fifth is long, so the ring reads as a scale. */
const ticks = Array.from({ length: N }, (_, i) => {
const a = (i / N) * Math.PI * 2;
const [x1, y1] = at(R, a);
const [x2, y2] = at(R - (i % 5 === 0 ? 20 : 10), a);
return { x1: f(x1), y1: f(y1), x2: f(x2), y2: f(y2) };
});
/* Corner brackets, each an L drawn about the centre and moved in by CSS. */
const B = 112, arm = 30;
const brackets = [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sy]) =>
`M${C + sx * B},${C + sy * (B - arm)} L${C + sx * B},${C + sy * B} L${C + sx * (B - arm)},${C + sy * B}`);
---
<svg class={`seal seal--${state}`} viewBox="0 0 400 400" role="img" aria-label={label} xmlns="http://www.w3.org/2000/svg">
<g class="seal__ticks">
{ticks.map((t) => <line x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} />)}
</g>
<g class="seal__rings">
<circle cx={C} cy={C} r="150" />
<circle class="seal__dashed" cx={C} cy={C} r="122" />
<line x1={C} y1="58" x2={C} y2="132" />
<line x1={C} y1="268" x2={C} y2="342" />
<line x1="58" y1={C} x2="132" y2={C} />
<line x1="268" y1={C} x2="342" y2={C} />
</g>
<g class="seal__brackets">
{brackets.map((d) => <path d={d} />)}
</g>
<circle class="seal__core" cx={C} cy={C} r="52" pathLength="1" />
{state === 'confirmed'
? <path class="seal__mark" d="M176,201 L194,219 L226,184" pathLength="1" />
: <path class="seal__mark" d="M182,182 L218,218 M218,182 L182,218" pathLength="1" />}
</svg>
<style>
.seal {
display: block;
width: 100%;
height: auto;
fill: none;
stroke: var(--ink);
/* Drawn small (a few rem), so the strokes are set heavy in the 400-unit box to land near 1px. */
stroke-width: 3;
stroke-linecap: square;
overflow: visible;
/* The sequence: lock, then the mark. */
--t-lock: 200ms;
--t-mark: 700ms;
}
.seal__ticks { stroke: var(--ink-muted); }
.seal__rings { stroke: var(--ink-muted); stroke-opacity: 0.45; }
.seal__dashed { stroke-dasharray: 5 11; }
.seal__brackets path {
stroke-width: 6;
transform-origin: 200px 200px;
animation: seal-lock 520ms var(--ease) both var(--t-lock);
}
.seal__core { stroke-width: 6; }
.seal__core, .seal__mark {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: seal-draw 600ms var(--ease) forwards var(--t-mark);
}
.seal__mark { stroke-width: 11; stroke-linecap: butt; animation-delay: calc(var(--t-mark) + 200ms); }
.seal--error .seal__core { stroke: var(--ink-muted); stroke-dasharray: 0.02 0.03; }
.seal--error .seal__brackets path { animation-name: seal-miss; }
@keyframes seal-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes seal-lock {
from { opacity: 0; transform: scale(1.5); }
to { opacity: 1; transform: scale(1); }
}
/* A failed lock: the brackets come in but stop short. */
@keyframes seal-miss {
from { opacity: 0; transform: scale(1.5); }
to { opacity: 0.5; transform: scale(1.2); }
}
@keyframes seal-draw { to { stroke-dashoffset: 0; } }
.seal--error .seal__core { animation: seal-fade 600ms var(--ease) both var(--t-mark); stroke-dashoffset: 0; }
@media (prefers-reduced-motion: reduce) {
.seal * { animation: none !important; }
.seal__core, .seal__mark { stroke-dashoffset: 0; }
.seal--error .seal__brackets path { opacity: 0.5; transform: scale(1.2); }
.seal--error .seal__core { stroke-dasharray: 0.02 0.03; }
}
</style>
+270
View File
@@ -0,0 +1,270 @@
---
import Toast from '../Toast.astro';
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const s = ui.verifier.soon;
const n = s.notify;
/* The background: a relief of thin ridge lines, computed once at build time. Each line is a row
of smooth value noise, lifted most on the right where the page is quiet and nearly flat under
the text on the left. Rows are drawn back to front and filled with night, so a nearer ridge
hides the one behind it. Colour runs through the logo blues, deep at the back to cyan in front. */
const W = 1600, H = 1000, rows = 46, steps = 160;
const hash = (x: number, y: number) => {
const h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return h - Math.floor(h);
};
const smooth = (t: number) => t * t * (3 - 2 * t);
const value = (x: number, y: number) => {
const xi = Math.floor(x), yi = Math.floor(y), xf = smooth(x - xi), yf = smooth(y - yi);
const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
return a + (b - a) * xf + (c - a) * yf + (a - b - c + d) * xf * yf;
};
const fbm = (x: number, y: number) => value(x, y) * 0.6 + value(x * 2.1 + 5, y * 2.1) * 0.28 + value(x * 4.3 + 9, y * 4.3) * 0.12;
const ridges = Array.from({ length: rows }, (_, r) => {
const depth = r / (rows - 1);
const base = H * 0.22 + depth * H * 0.78;
const pts = Array.from({ length: steps + 1 }, (_, i) => {
const x = (i / steps) * W;
const u = x / W;
const envelope = Math.exp(-((u - 0.7) ** 2) / 0.045) * (0.35 + 0.65 * Math.sin(depth * Math.PI));
const lift = Math.max(0, fbm(u * 5.5, r * 0.22) - 0.3) * 480 * envelope;
return `${x.toFixed(1)},${(base - lift).toFixed(1)}`;
});
return {
d: `M${pts.join(' L')} L${W},${H} L0,${H} Z`,
mix: Math.round(depth * 100),
alpha: (0.35 + depth * 0.5).toFixed(2),
};
});
---
<section class="shero night" aria-labelledby="shero-title">
<div class="shero__media" aria-hidden="true">
<svg class="shero__relief" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg">
{ridges.map((r) => (
<path d={r.d} style={`--mix: ${r.mix}%; --alpha: ${r.alpha}`} />
))}
</svg>
<div class="shero__overlay"></div>
</div>
<div class="container shero__inner">
<!-- First in the document, last on screen: the product name, set across the full width. -->
<h1 id="shero-title" class="shero__name">{s.title[0].replace(/\.$/, '')}<span class="visually-hidden">. {s.title[1]}</span></h1>
<div class="shero__text">
<p class="eyebrow">{s.eyebrow}</p>
<p class="shero__soon" aria-hidden="true">{s.title[1]}</p>
<p class="shero__lead">{s.lead}</p>
</div>
<form
class="signup hud-frame"
id="notify"
action="/notify"
method="post"
lang={lang}
aria-labelledby="notify-title"
data-notify
data-sent={n.status.sent}
data-invalid={n.status.invalid}
data-failed={n.status.failed}
>
<div class="signup__head">
<h2 id="notify-title" class="signup__title">{n.title}</h2>
<p class="signup__body">{n.body}</p>
</div>
<input type="hidden" name="lang" value={lang} />
<input type="hidden" name="list" value="cipherverifier-alpha" />
<p class="visually-hidden" aria-hidden="true">
<label for="notify-website">{ui.contact.form.honeypot}</label>
<input type="text" id="notify-website" name="website" tabindex="-1" autocomplete="off" />
</p>
<div class="signup__field">
<label class="signup__label" for="notify-email">{n.email}</label>
<input class="signup__input" id="notify-email" name="email" type="email" autocomplete="email" placeholder={n.placeholder} required />
</div>
<div class="signup__consent">
<input id="notify-consent" name="consent" type="checkbox" value="yes" required />
<label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label>
</div>
<button class="button signup__submit" type="submit">{n.submit}</button>
<noscript><p class="signup__status">{n.noscript}</p></noscript>
</form>
<!-- Status strip: the menu bar's HUD language, set along the foot of the hero. -->
<dl class="readout hud-frame" aria-label={s.status.label}>
<div class="readout__cell readout__cell--head">{s.status.label}</div>
{s.status.rows.map((r) => (
<div class="readout__cell">
<dt>{r.key}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
</div>
<Toast for="notify" labels={n.toast} />
</section>
<style>
.shero {
position: relative;
/* Runs up under the floating header, as the home hero does, so the bar takes the night tone. */
margin-top: calc(-1 * var(--header-offset));
overflow: hidden;
}
.shero__media { position: absolute; inset: 0; animation: shero-settle var(--dur-hero) var(--ease) both; transform-origin: 60% 50%; }
.shero__relief { width: 100%; height: 100%; display: block; }
.shero__relief path {
fill: var(--night);
stroke: color-mix(in oklab, var(--logo-deep), var(--logo-cyan) var(--mix));
stroke-opacity: var(--alpha);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
/* Darker on the left, where the type sits. */
.shero__overlay {
position: absolute;
inset: 0;
background: linear-gradient(to right, rgb(14 16 15 / 0.7), rgb(14 16 15 / 0) 60%);
}
.shero__inner {
position: relative;
display: grid;
gap: var(--space-8);
padding-top: calc(var(--header-offset) + var(--space-7));
min-height: 100svh;
align-content: end;
}
.shero__text { display: grid; gap: var(--space-4); justify-items: start; }
.shero__soon {
font-size: var(--step-h2);
letter-spacing: var(--track-h2);
line-height: var(--leading-h2);
font-weight: var(--weight-medium);
}
.shero__lead { max-width: 26em; color: var(--ink-muted); }
/* The name: one word across the container, its lower edge cut by the section. */
.shero__name {
order: 9;
font-size: var(--step-giant);
font-weight: var(--weight-medium);
letter-spacing: var(--track-display);
line-height: 0.8;
white-space: nowrap;
margin-bottom: -0.1em;
margin-left: -0.05em;
}
/* The one load moment: the image settles, then the type arrives line by line. */
@keyframes shero-settle { from { transform: scale(1.045); } to { transform: none; } }
.shero__soon, .shero__lead, .signup, .readout, .shero__name {
opacity: 0;
animation: shero-in var(--dur-slow) var(--ease) forwards;
}
.shero__name { animation-duration: 900ms; animation-delay: 200ms; }
.shero__soon { animation-delay: 500ms; }
.shero__lead { animation-delay: 640ms; }
.signup, .readout { animation-delay: 780ms; }
@keyframes shero-in { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.shero__media { animation: none; }
.shero__soon, .shero__lead, .signup, .readout, .shero__name { animation: none; opacity: 1; }
}
/* Sign-up: a framed instrument panel. */
.signup {
--hud-line: var(--hud-line-on-night);
display: grid;
gap: var(--space-5);
padding: var(--space-6);
background: var(--panel-on-night);
align-content: start;
justify-items: start;
}
.signup__head { display: grid; gap: var(--space-2); }
.signup__title { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); font-weight: var(--weight-medium); }
.signup__body { color: var(--ink-muted); }
.signup__field { display: grid; gap: var(--space-2); width: 100%; }
.signup__label { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
.signup__input {
width: 100%;
min-width: 0;
background: transparent;
border: 0;
border-bottom: var(--hairline) solid var(--line);
border-radius: 0;
padding: var(--space-3) 0;
transition: border-color var(--dur-fast) var(--ease);
}
.signup__input::placeholder { color: var(--ink-muted); opacity: 0.7; }
.signup__input:focus-visible { border-bottom-color: currentColor; }
.signup__consent {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: start;
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted);
}
.signup__consent input { width: 1.1em; height: 1.1em; margin: 0.15em 0 0; accent-color: var(--paper); }
.signup__status { font-size: var(--step-small); line-height: var(--leading-small); }
.signup__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
/* Readout: stacked on small screens, one strip of cells on wide ones. */
.readout {
--hud-line: var(--hud-line-on-night);
display: grid;
font-family: var(--font-mono);
font-size: var(--step-micro);
letter-spacing: var(--track-caps);
text-transform: uppercase;
line-height: var(--leading-small);
background: var(--panel-on-night);
}
.readout__cell {
display: grid;
grid-template-columns: 8rem 1fr;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.readout__cell + .readout__cell { border-top: var(--hairline) solid var(--line-on-night); }
.readout__cell--head, .readout dt { color: var(--ink-muted); }
@media (max-width: 30rem) {
.signup { padding: var(--space-5); }
}
/* Wide: the line and lead on the left, sign-up on the right, the strip, then the name. */
@media (min-width: 64rem) {
.shero__inner {
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--gap);
row-gap: var(--space-6);
align-items: end;
}
.signup { padding: var(--space-5) var(--space-6); gap: var(--space-4); }
.shero__text { grid-column: 1 / span 6; }
.signup { grid-column: 9 / span 4; }
.readout { grid-column: 1 / -1; grid-template-columns: 1fr 2fr 2fr 2fr; }
.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>
<script>
// Sends in place and answers in the toast, like the contact form; see scripts/send-form.ts.
// Without scripting the server refuses the post and redirects to /notify/error.
import { sendInPlace } from '../../scripts/send-form';
const form = document.querySelector<HTMLFormElement>('[data-notify]');
if (form) sendInPlace(form);
</script>
+329
View File
@@ -0,0 +1,329 @@
---
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const v = t(lang).verifier.soon.verticals;
/* The first tile is the vertical that is integrated; the others are open slots. */
const tiles = v.tiles.map((tile, i) => ({ ...tile, id: `vertical-${i + 1}`, kind: i === 0 ? 'telecom' : 'open' }));
---
<section class="section verticals" aria-labelledby="verticals-title">
<div class="container grid">
<div class="verticals__head">
<h2 id="verticals-title" class="h2">{v.title}</h2>
<p class="muted">{v.body}</p>
</div>
<div class="verticals__system" data-verticals>
<p class="verticals__platform small">{v.platform}</p>
<div class="verticals__tiles" role="tablist" aria-labelledby="verticals-title">
{tiles.map((tile, i) => (
<button
class="verticals__tile hud-frame"
type="button"
role="tab"
id={`${tile.id}-tab`}
aria-controls={`${tile.id}-panel`}
aria-selected={i === 0 ? 'true' : 'false'}
tabindex={i === 0 ? undefined : -1}
data-kind={tile.kind}
>
<span class="verticals__state small">
<span class="verticals__mark" aria-hidden="true" />
{tile.state}
</span>
{tile.kind === 'telecom' ? (
/* A lattice mast with its antenna panels, radiating to both sides, two cabinets at the foot. */
<svg class="verticals__drawing" viewBox="0 0 240 150" aria-hidden="true">
<g class="verticals__idea">
<path d="M104 132 L117 34 M136 132 L123 34 M117 34 L123 34 M120 34 L120 14" />
<path d="M105 124 L134 112 L107 100 L132 88 L109 76 L130 64 L112 52 L128 42" />
<path d="M110 40 h-5 v14 h5 z M130 40 h5 v14 h-5 z" />
<path d="M96 28 A30 30 0 0 0 96 66 M86 18 A44 44 0 0 0 86 76 M76 8 A58 58 0 0 0 76 86" />
<path d="M144 28 A30 30 0 0 1 144 66 M154 18 A44 44 0 0 1 154 76 M164 8 A58 58 0 0 1 164 86" />
</g>
<g class="verticals__ground">
<path d="M40 132 H200" />
<path d="M62 132 v-14 h18 v14 M160 132 v-14 h18 v14 M80 125 H104 M136 125 H160" />
</g>
</svg>
) : (
/* An empty module, drawn dashed: the slot the next vertical plugs into. */
<svg class="verticals__drawing" viewBox="0 0 240 150" aria-hidden="true">
<g class="verticals__idea">
<path d="M120 14 V34" />
<path class="verticals__dashed" d="M120 34 L162 56 L120 78 L78 56 Z M78 56 V104 L120 126 L162 104 V56 M120 78 V126" />
<path d="M120 49 v14 M113 56 h14" />
</g>
<g class="verticals__ground">
<path d="M40 132 H200" />
<path d="M60 22 v-8 h8 M180 22 v-8 h-8 M60 118 v8 h8 M180 118 v8 h-8" />
</g>
</svg>
)}
<span class="verticals__name">{tile.name}</span>
</button>
))}
</div>
<div class="verticals__panels">
{tiles.map((tile, i) => (
<div
class="verticals__panel"
role="tabpanel"
id={`${tile.id}-panel`}
aria-labelledby={`${tile.id}-tab`}
tabindex="0"
data-open={i === 0 ? '' : undefined}
>
<div class="verticals__copy">
<h3 class="h3">{tile.title}</h3>
<p class="muted">{tile.body}</p>
</div>
{'rows' in tile && tile.rows && (
<dl class="verticals__rows">
{tile.rows.map((r) => (
<div class="verticals__row">
<dt>{r.key}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
)}
{'cta' in tile && tile.cta && (
<p class="verticals__cta"><a class="button" href={`${localePath(lang, '/')}#contact`}>{tile.cta}</a></p>
)}
</div>
))}
</div>
</div>
</div>
</section>
<style>
/* Verticals: the platform on top, a bus below it, one tile per vertical plugged into the bus.
The selected tile turns night; its panel below says what the vertical covers. */
.verticals { border-top: var(--hairline) solid var(--line); }
.verticals__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
.verticals__head p { max-width: 24em; }
.verticals__system { grid-column: 1 / -1; display: grid; }
/* Phones: the platform sits left, a spine runs down from it and each tile plugs in from the
side. Wider: the platform is centred over a bus and each tile drops a stub from it. */
.verticals__system { --stub: var(--space-5); --spine: var(--space-4); --indent: var(--space-7); }
.verticals__platform {
justify-self: start;
position: relative;
padding: var(--space-2) var(--space-4);
border: var(--hairline) solid var(--ink);
font-weight: var(--weight-medium);
}
.verticals__platform::after {
content: '';
position: absolute;
top: 100%;
left: var(--spine);
height: var(--stub);
border-left: var(--hairline) solid var(--ink);
}
.verticals__tiles {
position: relative;
display: grid;
grid-auto-rows: 1fr;
gap: var(--gap);
margin-top: var(--stub);
padding-left: var(--indent);
}
/* .hud-frame redefines --line as a width, so the frame colour comes from the HUD token. */
.verticals__tile {
--frame-color: var(--hud-line);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr);
grid-template-rows: auto 1fr;
column-gap: var(--space-4);
row-gap: var(--space-5);
padding: var(--space-4);
text-align: left;
font: inherit;
color: var(--ink);
background: var(--paper);
cursor: pointer;
transition:
background var(--dur) var(--ease),
color var(--dur) var(--ease);
}
/* The plug: down the spine from the tile above (or the platform), then across into the tile.
Rows share one height, so -50% of a tile reaches the middle of the one before it. */
.verticals__tile::before {
content: '';
position: absolute;
right: 100%;
width: calc(var(--indent) - var(--spine));
top: calc(-50% - var(--gap));
bottom: 50%;
border-left: var(--hairline) solid var(--ink);
border-bottom: var(--hairline) solid var(--ink);
}
.verticals__tile:first-child::before { top: calc(-1 * var(--stub)); }
/* An open slot is plugged in with a dashed line: reserved, not yet connected. */
.verticals__tile[data-kind='open']::before { border-left-style: dashed; border-bottom-style: dashed; }
.verticals__tile[aria-selected='true'] {
--frame-color: var(--ink-base);
--ink-muted: var(--ink-muted-on-night);
background: var(--night);
color: var(--ink-on-night);
cursor: default;
}
/* The ring sits outside the tile, on paper, so it keeps the paper ink even on the night tile. */
.verticals__tile:focus-visible { outline-color: var(--ink-base); }
/* Crosses only on the selected tile: it is the one under inspection. */
.verticals__tile[aria-selected='false']::after { opacity: 0; }
.verticals__tile::after { transition: opacity var(--dur) var(--ease); }
.verticals__state {
grid-column: 1;
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--ink-muted);
}
/* A square that fills when the tile is selected, like a selector on a panel. */
.verticals__mark {
width: 0.5rem;
height: 0.5rem;
border: var(--hairline) solid currentColor;
flex: none;
transition: background var(--dur) var(--ease);
}
.verticals__tile[aria-selected='true'] .verticals__mark { background: var(--ink-on-night); border-color: var(--ink-on-night); }
.verticals__drawing {
grid-column: 2;
grid-row: 1 / span 2;
width: 100%;
max-width: 22rem;
justify-self: center;
align-self: center;
height: auto;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: var(--hairline);
stroke-linecap: square;
vector-effect: non-scaling-stroke;
transform-origin: 50% 60%;
transition: transform var(--dur-slow) var(--ease);
}
.verticals__drawing path { vector-effect: non-scaling-stroke; }
.verticals__ground { opacity: 0.4; transition: opacity var(--dur-slow) var(--ease); }
.verticals__dashed { stroke-dasharray: 4 4; }
.verticals__name {
grid-column: 1;
align-self: end;
font-size: var(--step-h3);
font-weight: var(--weight-medium);
letter-spacing: var(--track-h3);
line-height: var(--leading-h3);
}
/* Pointing at a tile brings its drawing forward, as on the home page cards. */
@media (hover: hover) {
.verticals__tile[aria-selected='false']:hover { background: color-mix(in srgb, var(--ink) var(--tint-hover), var(--paper)); }
.verticals__tile:hover .verticals__drawing { transform: scale(1.03); }
.verticals__tile:hover .verticals__ground { opacity: 0.26; }
}
/* Panels share one cell, so switching crossfades without the section changing height. */
.verticals__panels {
display: grid;
margin-top: var(--space-6);
border-top: var(--hairline) solid var(--ink);
}
.verticals__panel {
grid-area: 1 / 1;
display: grid;
gap: var(--space-5);
align-content: start;
padding-top: var(--space-5);
opacity: 0;
visibility: hidden;
transition:
opacity var(--dur) var(--ease),
visibility 0s linear var(--dur);
}
.verticals__panel[data-open] {
opacity: 1;
visibility: visible;
transition: opacity var(--dur-slow) var(--ease) var(--dur-fast), visibility 0s;
}
.verticals__copy { display: grid; gap: var(--space-3); }
.verticals__copy p { max-width: var(--measure); }
.verticals__rows { border-top: var(--hairline) solid var(--line); }
.verticals__row {
display: grid;
grid-template-columns: minmax(7rem, 1fr) 2fr;
gap: var(--gap);
padding-block: var(--space-3);
border-bottom: var(--hairline) solid var(--line);
}
.verticals__row dt { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); padding-top: 0.15em; }
@media (min-width: 48rem) {
.verticals__system { --stub: var(--space-6); }
.verticals__platform { justify-self: center; }
.verticals__platform::after { left: 50%; }
.verticals__tiles {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: auto;
margin-top: calc(2 * var(--stub));
padding-left: 0;
}
/* The bus runs between the centres of the two tiles. */
.verticals__tiles::before {
content: '';
position: absolute;
top: calc(-1 * var(--stub));
left: calc(25% - var(--gap) / 4);
right: calc(25% - var(--gap) / 4);
border-top: var(--hairline) solid var(--ink);
}
.verticals__tile {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
row-gap: var(--space-4);
padding: var(--space-5);
}
.verticals__tile::before,
.verticals__tile:first-child::before {
top: auto;
bottom: 100%;
left: 50%;
right: auto;
width: 0;
height: var(--stub);
border-bottom: 0;
}
.verticals__state, .verticals__drawing, .verticals__name { grid-column: 1; grid-row: auto; }
.verticals__drawing { padding-block: var(--space-4); }
.verticals__panel { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); column-gap: var(--gap); }
.verticals__rows, .verticals__cta { align-self: start; margin-top: var(--space-1); }
}
@media (min-width: 64rem) {
.verticals__head { grid-column: 1 / span 4; }
.verticals__system { grid-column: 5 / span 8; }
}
@media (prefers-reduced-motion: reduce) {
.verticals__tile, .verticals__tile::after, .verticals__mark, .verticals__drawing, .verticals__ground, .verticals__panel, .verticals__panel[data-open] { transition: none; }
}
</style>
<script>
import { initTabs } from '../../scripts/tabs';
const root = document.querySelector<HTMLElement>('[data-verticals]');
if (root) initTabs(root);
</script>
+8 -4
View File
@@ -13,7 +13,9 @@ export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean; group?: string } export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean; group?: string }
export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean } export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean }
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean; group?: string } export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean; group?: string }
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number } /* `lit` names, per group, the other groups a hover on it keeps lit: a path that leaves one node
and ends at another belongs to both. */
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number; lit?: Record<string, string[]> }
export interface Pair { wide: Layout; tall: Layout } export interface Pair { wide: Layout; tall: Layout }
const RING = 36; const RING = 36;
@@ -124,11 +126,13 @@ export function verdict(g: Groups[2]): Pair {
nodes: [dot(140, y, L.finding, 'finding'), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes], nodes: [dot(140, y, L.finding, 'finding'), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
edges: [ edges: [
...ctx.edges, ...tools.edges, ...outcomes.edges, ...ctx.edges, ...tools.edges, ...outcomes.edges,
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]), ...chain([[140, y], [380, y], [640, y], [920, y]]),
{ points: [[640, y], [640, top], [1220, top], [1220, y]], tick: true }, { points: [[920, y], [1220, y]], tick: true, group: 'escalation' },
{ points: [[640, y], [640, top], [1220, top], [1220, y]], tick: true, group: 'static' },
], ],
texts: [...v.texts, { x: 930, y: top - 12, text: L.certain, anchor: 'middle' }, { x: 780, y: y + 30, text: splitTwo(L.unsure), anchor: 'middle' }], texts: [...v.texts, { x: 930, y: top - 12, text: L.certain, anchor: 'middle', group: 'static' }, { x: 780, y: y - 52, text: splitTwo(L.unsure), anchor: 'middle' }],
fontMain: 19, fontSub: 17, fontMain: 19, fontSub: 17,
lit: { static: ['verdict'], escalation: ['verdict'], verdict: ['static'] },
}; };
const tall = spine([ const tall = spine([
{ title: L.finding }, { title: L.finding },
+1 -1
View File
@@ -3,7 +3,7 @@ export interface Group<L> { title: string; body: string; desc: string; labels: L
export interface OverviewLabels { repo: string; sbom: string[]; sbomSub: string; dt: string[]; dtSub: string; sast: string[]; sastSub: string; poly: string[]; polySub: string; prism: string[]; prismSub: string; report: string } export interface OverviewLabels { repo: string; sbom: string[]; sbomSub: string; dt: string[]; dtSub: string; sast: string[]; sastSub: string; poly: string[]; polySub: string; prism: string[]; prismSub: string; report: string }
export interface ScannersLabels { one: string; oneSub: string; cwe: string[]; cweSub: string; merge: string[]; mergeSub: string; resolve: string[]; resolveSub: string } export interface ScannersLabels { one: string; oneSub: string; cwe: string[]; cweSub: string; merge: string[]; mergeSub: string; resolve: string[]; resolveSub: string }
export interface VerdictLabels { finding: string; context: string; ctx: string[]; stage1: string[]; stage2: string; tools: string[]; verdict: string; outcomes: string[]; carries: string; certain: string; unsure: string } export interface VerdictLabels { finding: string; context: string; ctx: string[]; stage1: string[]; stage2: string[]; tools: string[]; verdict: string; outcomes: string[]; carries: string; certain: string; unsure: string }
export interface DependenciesLabels { syft: string; cdxgen: string; sbom: string; sbomSub: string[]; dt: string; dtSub: string[]; vdr: string[]; vdrSub: string; report: string } export interface DependenciesLabels { syft: string; cdxgen: string; sbom: string; sbomSub: string[]; dt: string; dtSub: string[]; vdr: string[]; vdrSub: string; report: string }
export interface OperateLabels { hub: string; sources: string[]; outputs: string[]; scopes: string[]; scopesTitle: string } export interface OperateLabels { hub: string; sources: string[]; outputs: string[]; scopes: string[]; scopesTitle: string }
+33 -3
View File
@@ -15,10 +15,40 @@ const insights = defineCollection({
readingTime: z.number().int().positive(), readingTime: z.number().int().positive(),
author: z.string(), author: z.string(),
authorRole: z.string(), authorRole: z.string(),
cover: image(), /* Optional: without a cover the cards show a colour field (src/components/Field.astro)
coverAlt: z.string(), and the essay page opens without an image. */
cover: image().optional(),
coverAlt: z.string().optional(),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
}).refine((d) => !d.cover || d.coverAlt, { message: 'coverAlt is required with a cover', path: ['coverAlt'] }),
});
/* Mentions: short references to posts elsewhere (LinkedIn first) that CipherCell took part in.
Each gets a short page under /insights/<slug> with the post embedded on request. Query and
fragment are dropped from the URL, so tracking and member IDs from a share link never reach
the site. `embed` is derived from a LinkedIn post URL; set it by hand only if that fails. */
const mentions = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/mentions' }),
schema: z.object({
title: z.string(),
summary: z.string(),
url: z.url().transform((u) => {
const clean = new URL(u);
clean.search = '';
clean.hash = '';
return clean.toString();
}),
source: z.string(),
platform: z.string().default('LinkedIn'),
embed: z.url().optional(),
embedHeight: z.number().int().positive().default(720),
date: z.coerce.date(),
lang: z.enum(['en', 'de']),
}).transform((d) => {
const activity = d.url.match(/activity[-:](\d+)/)?.[1];
const embed = d.embed ?? (activity ? `https://www.linkedin.com/embed/feed/update/urn:li:activity:${activity}` : undefined);
return { ...d, embed };
}), }),
}); });
export const collections = { insights }; export const collections = { insights, mentions };
@@ -0,0 +1,9 @@
---
title: 'CipherCell at the Berlin 6G Conference 2026.'
summary: 'xG-Incubator announces Florian Frank, Marc Schlotzhauer and Felix Klement at StartUpConnect booth 20, with a demo that analyses software for telecommunications services statically, automatically and deterministically, air-gapped in the customer''s infrastructure. Felix Klement also joins the panel "AI for Trustworthiness".'
url: 'https://www.linkedin.com/posts/xg-incubator_ciphercell-berlin-6g-conference-2026-activity-7499042305199366144-lS85'
source: 'xG-Incubator'
platform: 'LinkedIn'
date: 2026-08-29
lang: en
---
@@ -0,0 +1,9 @@
---
title: 'Component security testing at the xG-ALOE Plugfest 2026.'
summary: 'xG-ALOE welcomes Felix Klement and Florian Frank of the University of Passau to the Plugfest in Berlin. They show a security testing platform for xG software: deterministic vulnerability detection with targeted AI support, with a focus on O-RAN.'
url: 'https://www.linkedin.com/posts/xg-aloe_xgaloe-plugfest2026-openran-activity-7506238009315454976-HZPG'
source: 'xG-ALOE'
platform: 'LinkedIn'
date: 2026-09-17
lang: en
---
@@ -0,0 +1,9 @@
---
title: 'Two days on 5G and xG security in Nuremberg.'
summary: 'At the xG-ALOE User Forum 2026, hosted by Fraunhofer IIS, Felix Klement and Heiner Grottendieck of the BSI led the tutorial "Securing the Evolution to xG" on the key security challenges of next-generation networks.'
url: 'https://lnkd.in/p/eMDeMETT'
source: 'CipherCell'
platform: 'LinkedIn'
date: 2026-06-26
lang: en
---
@@ -0,0 +1,9 @@
---
title: 'Tutorial B2 at the xG-ALOE User Forum 2026.'
summary: 'xG-ALOE announces the joint tutorial "Securing the Evolution to xG" by Felix Klement (University of Passau) and Heiner Grottendieck (BSI): current threat landscapes, security challenges in open telco architectures, and ways to strengthen network resilience. Fraunhofer IIS, Nuremberg, 24 and 25 June 2026.'
url: 'https://www.linkedin.com/posts/xg-aloe_b2-5g-xg-activity-7459863696824262656-QRIN'
source: 'xG-ALOE'
platform: 'LinkedIn'
date: 2026-05-12
lang: en
---
+9
View File
@@ -0,0 +1,9 @@
/* Matomo, self-hosted. The tracker runs without cookies and honours Do Not Track; the privacy
page states both, and its opt-out (src/components/OptOut.astro) talks to the same tracker.
Only production builds load it, so local development is never counted. */
export const matomo = {
url: 'https://matomo.ciphercell.de/',
/* The site's ID in Matomo: Administration, Websites, Manage. */
siteId: '1',
enabled: import.meta.env.PROD,
};
+5
View File
@@ -0,0 +1,5 @@
/* Site-wide switches. Flip a flag and rebuild; nothing else needs to change. */
/* While true, /cipherverifier shows the coming-soon page with the alpha sign-up instead of the
full product page. The full page (src/components/verifier/*) is kept as it is. */
export const verifierComingSoon = true;
+21
View File
@@ -0,0 +1,21 @@
/* The Insights feed: essays (own pages) and mentions (links out), newest first.
One list so the index and the home page sort and slice them the same way. */
import { getCollection, type CollectionEntry } from 'astro:content';
export type FeedItem =
| { kind: 'post'; date: Date; entry: CollectionEntry<'insights'> }
| { kind: 'mention'; date: Date; entry: CollectionEntry<'mentions'> };
export async function getFeed(): Promise<FeedItem[]> {
const posts = (await getCollection('insights')).map((entry) => ({ kind: 'post' as const, date: entry.data.date, entry }));
const mentions = (await getCollection('mentions')).map((entry) => ({ kind: 'mention' as const, date: entry.data.date, entry }));
return [...posts, ...mentions].sort((a, b) => b.date.valueOf() - a.date.valueOf());
}
/* The filter an item belongs to on the Insights index: essays, LinkedIn, or any other platform. */
export type FeedType = 'essay' | 'linkedin' | 'other';
export const feedTypes: FeedType[] = ['essay', 'linkedin', 'other'];
export function feedType(item: FeedItem): FeedType {
if (item.kind === 'post') return 'essay';
return item.entry.data.platform.toLowerCase() === 'linkedin' ? 'linkedin' : 'other';
}
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
{
"profile": "https://scholar.google.com/citations?user=MRMV2cwAAAAJ&hl=en",
"fetchedAt": "2026-10-03",
"entries": [
{
"title": "Open or not open: Are conventional radio access networks more secure and trustworthy than Open-RAN?",
"authors": "F Klement, S Katzenbeisser, V Ulitzsch, J Krämer, S Stanczak, Z Utkovski, ...",
"venue": "arXiv preprint arXiv:2204.12227, 2022",
"cites": 35,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:d1gkVwhDpl0C"
},
{
"title": "Toward securing the 6G transition: A comprehensive empirical method to analyze threats in O-RAN environments",
"authors": "F Klement, W Liu, S Katzenbeisser",
"venue": "IEEE Journal on Selected Areas in Communications 42 (2), 420-431, 2023",
"cites": 29,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:W7OEmFMy1HYC"
},
{
"title": "Securing the open ran infrastructure: Exploring vulnerabilities in kubernetes deployments",
"authors": "F Klement, A Brighente, M Polese, M Conti, S Katzenbeisser",
"venue": "2024 IEEE 10th International Conference on Network Softwarization (NetSoft …, 2024",
"cites": 21,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:ufrVoPGSRksC"
},
{
"title": "Man-in-the-OBD: A modular, protocol agnostic firewall for automotive dongles to enhance privacy and security",
"authors": "F Klement, HC Pöhls, S Katzenbeisser",
"venue": "International Workshop on Attacks and Defenses for Internet-of-Things, 143-164, 2022",
"cites": 13,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:9yKSN-GCB0IC"
},
{
"title": "Endless subscriptions: open RAN is open to RIC E2 subscription denial of service attacks",
"authors": "F Klement, A Brighente, AK Bhattacharjee, S Cecconello, F Kuipers, ...",
"venue": "2025 IEEE 10th European Symposium on Security and Privacy (EuroS&P), 755-770, 2025",
"cites": 11,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:Se3iqnhoufwC"
},
{
"title": "Segmentation and Filtering Are Still the Gold Standard for Privacy in IoT—An In-Depth STRIDE and LINDDUN Analysis of Smart Homes",
"authors": "HC Pöhls, F Kügler, E Geloczi, F Klement",
"venue": "Future Internet 17 (2), 77, 2025",
"cites": 9,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:roLk4NBRz8UC"
},
{
"title": "Testing physical unclonable functions implemented on commercial off-the-shelf NAND flash memories using programming disturbances",
"authors": "NA Anagnostopoulos, Y Fan, MU Saleem, N Mexis, E Gelóczi, F Klement, ...",
"venue": "2022 IEEE 12th International Conference on Consumer Electronics (ICCE-Berlin …, 2022",
"cites": 8,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:UeHWp8X0CEIC"
},
{
"title": "Change Your Car's Filters: Efficient Concurrent and Multi-Stage Firewall for OBD-II Network Traffic",
"authors": "F Klement, HC Pöhls, S Katzenbeisser",
"venue": "2022 IEEE 27th International Workshop on Computer Aided Modeling and Design …, 2022",
"cites": 7,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:qjMakFHDy7sC"
},
{
"title": "Secure Data-Binding in FPGA-based Hardware Architectures utilizing PUFs",
"authors": "F Frank, M Schmid, F Klement, P Palani, A Weber, EB Kavun, W Xiong, ...",
"venue": "Proceedings of the 19th ACM Asia Conference on Computer and Communications …, 2024",
"cites": 5,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:_FxGoFyzp5QC"
},
{
"title": "One standard to rule them all? Assessing the disruptive potential of jamming attacks on Matter networks",
"authors": "F Klement, E Vorderwülbeke, S Katzenbeisser",
"venue": "2023 IEEE International Workshop on Information Forensics and Security (WIFS …, 2023",
"cites": 5,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:YsMSGLbcyi4C"
},
{
"title": "Ensuring trustworthy automated road vehicles: A software integrity validation approach",
"authors": "D Püllen, F Klement, A Vinel, S Katzenbeisser",
"venue": "2023 IEEE International Automated Vehicle Validation Conference (IAVVC), 1-8, 2023",
"cites": 5,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:Y0pCki6q_DkC"
},
{
"title": "SoK: delegated Security in the Internet of Things",
"authors": "E Geloczi, F Klement, P Struck, S Katzenbeisser",
"venue": "Future Internet 17 (5), 202, 2025",
"cites": 4,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:UebtZRa9Y70C"
},
{
"title": "Unveiling the shadows: An approach towards detection, precise localization, and effective isolation of concealed IoT devices in unfamiliar environments",
"authors": "E Geloczi, HC Pöhls, F Klement, J Posegga, S Katzenbeisser",
"venue": "Proceedings of the 23rd Workshop on Privacy in the Electronic Society, 109-123, 2023",
"cites": 4,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:LkGwnXOMwfcC"
},
{
"title": "Form Without Function: Agent Social Behavior in the Moltbook Network",
"authors": "S Zerhoudi, KG Dastidar, F Klement, A Romazanov, A Einwiller, DH Dang, ...",
"venue": "arXiv preprint arXiv:2604.13052, 2026",
"cites": 3,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:3fE2CSJIrl8C"
},
{
"title": "Private and secure over-the-air multi-party communication",
"authors": "JJ Brune, M Frey, F Klement, I Bjelaković, S Katzenbeisser, S Stańczak",
"venue": "2023 57th Asilomar Conference on Signals, Systems, and Computers, 1697-1701, 2023",
"cites": 3,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:WF5omc3nYNoC"
},
{
"title": "ORCA - An Automated Threat Analysis Pipeline for O-RAN Continuous Development",
"authors": "F Klement, A Brighente, M Polese, M Conti, S Katzenbeisser",
"venue": "arXiv preprint arXiv:2601.13681, 2026",
"cites": 2,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:8k81kl-MbHgC"
},
{
"title": "FlexRIC Subscription DoS Attack",
"authors": "F Klement",
"venue": "",
"cites": 2,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:5nxA0vEk-isC"
},
{
"title": "Secure Stitch: Unveiling the Fabric of Security Patterns for the Internet of Things",
"authors": "E Geloczi, F Klement, E Gründinger, S Katzenbeisser",
"venue": "International Workshop on Security and Trust Management, 65-84, 2023",
"cites": 2,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:zYLM7Y9cAGgC"
},
{
"title": "Keep your Enemies closer: On the minimal Distance of Adversaries when using Channel-based Key Extraction in SISO 6G Systems",
"authors": "F Klement, S Takebuchi, T Arul, S Katzenbeisser",
"venue": "2023 19th International Conference on Wireless and Mobile Computing …, 2023",
"cites": 2,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:IjCSPb-OGe4C"
},
{
"title": "Enhancing Scalability in Smart Device Networks: A Modular Adaptive IoT Framework for Efficient Resource Allocation and System Growth",
"authors": "M Waseem, F Klement",
"venue": "",
"cites": 1,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:0EnyYjriUFMC"
},
{
"title": "O-RAN Security: From Standardization and Threat Modeling to Practical Security Testing",
"authors": "J Moore, AS Abdalla, F Klement, AR Shakya, P Shantharama, V Marojevic",
"venue": "IEEE Open Journal of the Communications Society, 2026",
"cites": 0,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:KlAtU1dfN6UC"
},
{
"title": "6G Research and Innovation Cluster (6G-RIC)-Offene und sichere 6G-Technologien: Weltmarktchance für Deutschland",
"authors": "F Klement, S Katzenbeisser",
"venue": "Hannover: Technische Informationsbibliothek, 2026",
"cites": 0,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:kNdYIx-mwKoC"
},
{
"title": "Strengthening Security Foundations in Next-G Wireless Telecommunication Systems",
"authors": "F Klement",
"venue": "Universität Passau, 2025",
"cites": 0,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:hqOjcs7Dif8C"
},
{
"title": "1. Felix Klement, Korbinian Spielvogel, Henrich C. Pöhls (UP) 2. Konstantinos Fysarakis, Manolis Chatzimpyrros, Thodoris Galousis, Michalis Smyrlis (STS), 3. Nikolaos …",
"authors": "MF ST",
"venue": "",
"cites": 0,
"year": null,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:u-x6o8ySG0sC"
}
]
}
+174
View File
@@ -0,0 +1,174 @@
{
"profile": "https://scholar.google.com/citations?user=o9Gu_FMAAAAJ&hl=en",
"fetchedAt": "2026-10-03",
"entries": [
{
"title": "A security process for the automotive service-oriented software architecture",
"authors": "D Püllen, F Frank, M Christl, W Liu, S Katzenbeisser",
"venue": "IEEE Transactions on Vehicular Technology 73 (4), 5036-5053, 2023",
"cites": 15,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:LkGwnXOMwfcC"
},
{
"title": "Spatial correlation in weak physical unclonable functions: A comprehensive overview",
"authors": "N Mexis, T Arul, NA Anagnostopoulos, F Frank, S Böttger, M Hartmann, ...",
"venue": "2023 26th Euromicro Conference on Digital System Design (DSD), 70-78, 2023",
"cites": 14,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:roLk4NBRz8UC"
},
{
"title": "Using memristor arrays as physical unclonable functions",
"authors": "F Frank, T Arul, NA Anagnostopoulos, S Katzenbeisser",
"venue": "European Symposium on Research in Computer Security, 250-271, 2022",
"cites": 10,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:IjCSPb-OGe4C"
},
{
"title": "CNT-PUFs: Highly robust physical unclonable functions based on carbon nanotubes",
"authors": "S Böttger, F Frank, NA Anagnostopoulos, A Mohamed, M Hartmann, ...",
"venue": "2023 IEEE 23rd International Conference on Nanotechnology (NANO), 1-6, 2023",
"cites": 9,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:eQOLeE2rZwMC"
},
{
"title": "Testing physical unclonable functions implemented on commercial off-the-shelf NAND flash memories using programming disturbances",
"authors": "NA Anagnostopoulos, Y Fan, MU Saleem, N Mexis, E Gelóczi, F Klement, ...",
"venue": "2022 IEEE 12th International Conference on Consumer Electronics (ICCE-Berlin …, 2022",
"cites": 8,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:Tyk-4Ss8FVUC"
},
{
"title": "Abusing commodity DRAMs in IoT devices to remotely spy on temperature",
"authors": "F Frank, W Xiong, NA Anagnostopoulos, A Schaller, T Arul, F Koushanfar, ...",
"venue": "IEEE Transactions on Information Forensics and Security 18, 2991-3005, 2023",
"cites": 7,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:Y0pCki6q_DkC"
},
{
"title": "Investigation of Commercial Off-The-Shelf ReRAM Modules for Use as Runtime-Accessible TRNG",
"authors": "T Arul, N Mexis, AE George, F Frank, NA Anagnostopoulos, ...",
"venue": "2024 27th Euromicro Conference on Digital System Design (DSD), 33-42, 2024",
"cites": 5,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:3fE2CSJIrl8C"
},
{
"title": "Secure Data-Binding in FPGA-based Hardware Architectures utilizing PUFs",
"authors": "F Frank, M Schmid, F Klement, P Palani, A Weber, EB Kavun, W Xiong, ...",
"venue": "Proceedings of the 19th ACM Asia Conference on Computer and Communications …, 2024",
"cites": 5,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:UebtZRa9Y70C"
},
{
"title": "CNT-PUFs: highly robust and heat-tolerant carbon-nanotube-based physical unclonable functions",
"authors": "F Frank, S Böttger, N Mexis, NA Anagnostopoulos, A Mohamed, ...",
"venue": "Nanomaterials 13 (22), 2930, 2023",
"cites": 4,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:YsMSGLbcyi4C"
},
{
"title": "Secure communication via chaotic cryptography",
"authors": "E Gelóczi, N Mexis, NA Anagnostopoulos, F Frank, T Arul, SG Stavrinides, ...",
"venue": "crypto day matters 34, 2022",
"cites": 3,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:9yKSN-GCB0IC"
},
{
"title": "Fuzzy extractors using low-density parity-check codes",
"authors": "N Mexis, NA Anagnostopoulos, T Arul, F Frank, S Katzenbeisser",
"venue": "crypto day matters 33, 2021",
"cites": 3,
"year": 2021,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:d1gkVwhDpl0C"
},
{
"title": "Real-world chaos-based cryptography using synchronised Chua chaotic circuits",
"authors": "E Nazarenko, NA Anagnostopoulos, SG Stavrinides, N Mexis, F Frank, ...",
"venue": "TechRxiv 2023 (0717), 2023",
"cites": 2,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:UeHWp8X0CEIC"
},
{
"title": "A reconfigurable hardware infrastructure for evaluating PUFs in non-volatile memories",
"authors": "F Frank, S Katzenbeisser",
"venue": "HardwareX, e00809, 2026",
"cites": 1,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:KlAtU1dfN6UC"
},
{
"title": "Towards Deterministic DDS Communication for Secure Service-Oriented Software-Defined Vehicles",
"authors": "F Frank, D Püllen, A Kampmann, S Katzenbeisser",
"venue": "International Conference on Availability, Reliability and Security, 186-208, 2025",
"cites": 1,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:kNdYIx-mwKoC"
},
{
"title": "A Dedicated Mixed-Signal Characterisation and Testing Framework for Novel Digital Security Circuits That Use Carbon-Nanotube-Based Physical Unclonable Functions",
"authors": "F Frank, NA Anagnostopoulos, S Böttger, S Hermann, T Arul, ...",
"venue": "International Conference on Modern Circuits and Systems Technologies (MOCAST) 11, 2022",
"cites": 1,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:2osOgNQ5qMEC"
},
{
"title": "On the sustainability of lightweight cryptography based on PUFs implemented on NAND Flash memories using programming disturbances",
"authors": "NA Anagnostopoulos, Y Fan, MU Saleem, N Mexis, F Frank, T Arul, ...",
"venue": "arXiv preprint arXiv:2204.02498, 2022",
"cites": 1,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:u5HHmVD_uO8C"
},
{
"title": "Integrating physical unclonable functions from novel nanomaterials, circuit elements, and memory technologies into future hardware architectures",
"authors": "F Frank",
"venue": "Universität Passau, 2026",
"cites": 0,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:ULOm3_A8WrAC"
},
{
"title": "Securing Next Generation Service-Oriented Vehicle Architectures",
"authors": "F Frank, D Püllen, C Krauss, D Spychalski",
"venue": "autotech. agil, 2024",
"cites": 0,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:4TOpqqG69KYC"
},
{
"title": "Threat Analysis of the autotech. agil Cooperative Intelligent Transport System Ecosystem",
"authors": "F Frank, D Püllen, C Krauss, D Spychalski",
"venue": "autotech. agil, 2024",
"cites": 0,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:_kc_bZDykSQC"
},
{
"title": "Safety & Security Co-Engineering in Autonomous Driving and Future Mobility Systems",
"authors": "N Braun, F Frank, C Krauss, D Spychalski, R Schubert, D Püllen",
"venue": "autotech. agil, 2024",
"cites": 0,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:YOwf2qJgpHMC"
},
{
"title": "CNT-PUFs: Highly Robust and Heat-Tolerant Carbon-Nanotube-Based Physical Unclonable Functions for Stable Key Generation",
"authors": "S Böttger, F Frank, NA Anagnostopoulos, N Mexis, A Mohamed, ...",
"venue": "TechRxiv 2023 (0717), 2023",
"cites": 0,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:W7OEmFMy1HYC"
}
]
}
+9 -12
View File
@@ -8,24 +8,21 @@ import philipp from '../assets/images/people/philipp.png';
import karin from '../assets/images/people/karin.png'; import karin from '../assets/images/people/karin.png';
import valentin from '../assets/images/people/valentin.png'; import valentin from '../assets/images/people/valentin.png';
export type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM'; export type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM' | 'networkengineer';
export interface Person { export interface Person {
name: string; name: string;
role: RoleKey; role: RoleKey;
portrait: ImageMetadata; portrait: ImageMetadata;
github?: string;
linkedin?: string;
email: string;
} }
export const team: Person[] = [ export const team: Person[] = [
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix, github: 'https://github.com/fklement', linkedin: 'https://www.linkedin.com/in/felix-klement', email: 'felix.klement@ciphercell.de' }, { name: 'Dr. Felix Klement', role: 'ceo', portrait: felix },
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian, github: 'https://github.com/FlorianFrank', linkedin: 'https://www.linkedin.com/in/florianfrank93/', email: 'florian.frank@ciphercell.de' }, { name: 'Dr. Florian Frank', role: 'cto', portrait: florian },
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander, github: 'https://github.com/AlexanderBraml', linkedin: 'https://www.linkedin.com/in/alexanderbraml/', email: 'alexander.braml@ciphercell.de' }, { name: 'Alexander Braml', role: 'engineer', portrait: alexander },
{ name: 'Erik Foris', role: 'engineer', portrait: erik, github: 'https://github.com/ErikForis', email: 'erik.foris@ciphercell.de' }, { name: 'Erik Foris', role: 'networkengineer', portrait: erik },
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc, email: 'marc.schlotzhauer@ciphercell.de' }, { name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc },
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp, github: 'https://github.com/truePhilipp', linkedin: 'https://www.linkedin.com/in/philipp-mayr/', email: 'philipp.mayr@ciphercell.de' }, { name: 'Philipp Mayr', role: 'engineer', portrait: philipp },
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin, email: 'karin.kaufmann@ciphercell.de' }, { name: 'Karin Kaufmann', role: 'studentF', portrait: karin },
{ name: 'Valentin Hirte', role: 'studentM', portrait: valentin, email: 'valentin.hirte@ciphercell.de' }, { name: 'Valentin Hirte', role: 'studentM', portrait: valentin },
]; ];
+364 -103
View File
@@ -1,8 +1,6 @@
import type { HowGroups } from '../components/verifier/types'; import type { HowGroups } from '../components/verifier/types';
export type Lang = 'en' | 'de'; export type Lang = 'en' | 'de';
export const langs: Lang[] = ['en', 'de'];
export const defaultLang: Lang = 'en';
export function langFromUrl(url: URL): Lang { export function langFromUrl(url: URL): Lang {
const [, first] = url.pathname.split('/'); const [, first] = url.pathname.split('/');
@@ -30,7 +28,7 @@ export function formatDate(date: Date, lang: Lang): string {
const en = { const en = {
meta: { meta: {
siteName: 'CipherCell', siteName: 'CipherCell',
home: { title: 'CipherCell. Security engineering for software.', description: 'CipherCell is a security engineering company in Passau. Threat analysis, security engineering, verification and monitoring for the software behind private 5G, critical network infrastructure and cloud-native systems.' }, home: { title: 'CipherCell. Security engineering for software.', description: 'CipherCell is a security engineering company in Passau. Security, engineering and research for the software behind private 5G, critical network infrastructure and cloud-native systems.' },
team: { title: 'Team', description: 'The security researchers, engineers and architects behind CipherCell.' }, team: { title: 'Team', description: 'The security researchers, engineers and architects behind CipherCell.' },
verifier: { title: 'CipherVerifier', description: 'CipherVerifier is a self-hosted verification pipeline for software: seventeen static analysers and two SBOM generators, one vocabulary, a two-stage review of every finding, and a ranked list an engineer can act on.' }, verifier: { title: 'CipherVerifier', description: 'CipherVerifier is a self-hosted verification pipeline for software: seventeen static analysers and two SBOM generators, one vocabulary, a two-stage review of every finding, and a ranked list an engineer can act on.' },
insights: { title: 'Insights', description: 'Notes on software security in private 5G, critical networks and the systems that run them.' }, insights: { title: 'Insights', description: 'Notes on software security in private 5G, critical networks and the systems that run them.' },
@@ -39,7 +37,6 @@ const en = {
}, },
nav: { nav: {
skip: 'Skip to content', skip: 'Skip to content',
capabilities: 'Capabilities',
product: 'Product', product: 'Product',
sectors: 'Sectors', sectors: 'Sectors',
insights: 'Insights', insights: 'Insights',
@@ -50,75 +47,86 @@ const en = {
homeLabel: 'CipherCell, home', homeLabel: 'CipherCell, home',
}, },
hero: { hero: {
lines: ['Security for the software', 'everything else runs on.'], lines: ['Security for the software', 'everything runs on.'],
imageAlt: 'An industrial plant at dusk: pipe racks, storage tanks and a lattice mast carrying 5G antennas under an overcast sky.',
}, },
manifesto: { manifesto: {
lines: ['Security is built in,', 'not bolted on.'], lines: ['Security built in,', 'not bolted on.'],
p1: 'Infrastructure is software now. Private 5G networks, industrial control systems and cloud platforms are assembled from the code and components of many vendors. Every interface between them is an attack surface, and off-the-shelf tools were not built for that.', p1: 'Infrastructure is software now. Private 5G networks, industrial control systems, digital products and cloud platforms are assembled from the code and components of many vendors, and every interface between them is an attack surface that off-the-shelf tools were never designed to cover.',
p2: 'CipherCell is a security engineering company for software. We model real adversaries, build zero trust into the architecture rather than around it, and prove that the code holds. From the first threat model to the running system.', p2: 'CipherCell is a security engineering company built by specialists in networks and software. We take on the hard problems, whatever the vertical, and solve them with security at the core of every decision.',
cta: 'About CipherCell', cta: 'About CipherCell',
}, },
method: { method: {
lines: ['Four stages.', 'One method.'], lines: ['Three pillars.', 'One team.'],
body: 'Threat analysis, engineering, verification and monitoring, applied to your software as one continuous method rather than four separate projects. It runs alongside your development, modular and integrable.', body: ['We analyse, we build, we research.', 'Three disciplines, one expert team, working hand in hand.'],
imageAlt: 'A security engineer seen through glass, the reflections of monitoring screens layered across her face.', imageAlt: 'An empty monitoring room seen through glass, walls of screens and indicator lights blurred behind the panes.',
cta: 'Explore CipherVerifier',
diagramTitle: 'The CipherCell method', diagramTitle: 'The CipherCell method',
diagramDesc: 'A closed loop of four stages: threat analysis, engineering, verification and monitoring. Each stage carries its own instruments: threat model, attack paths and CI/CD integration; zero trust, key management and secure APIs; independent review, hardening and testing; intrusion detection, security logging and resilience checks. Monitoring feeds back into threat analysis.', diagramDesc: 'A triangle of three pillars, each joined to the other two: security, engineering and research. Each pillar carries its own instruments: threat modelling, automated threat analysis and security testing; secure architecture, FPGA and hardware design, and embedded and systems development; Open RAN and 6G security, hardware roots of trust, and vehicle and IoT security. Research feeds back into security.',
/* Pillars of the diagram and the footer column, which links them to this section. */
stages: [ stages: [
{ title: 'Threat analysis', items: ['Threat model', 'Attack paths', 'CI/CD integration'] }, { title: 'Security', items: ['Threat modelling', 'Automated threat analysis', 'Security testing'] },
{ title: 'Engineering', items: ['Zero trust', 'Key management', 'Secure APIs'] }, { title: 'Engineering', items: ['Secure architecture', 'FPGA and hardware design', 'Embedded and systems development'] },
{ title: 'Verification', items: ['Independent review', 'Hardening', 'Testing'] }, { title: 'Research', items: ['Open RAN and 6G security', 'Hardware roots of trust', 'Vehicle and IoT security'] },
{ title: 'Monitoring', items: ['Intrusion detection', 'Security logging', 'Resilience checks'] },
],
},
stages: {
eyebrow: 'What we do.',
items: [
{ title: 'Threat analysis', body: 'Automated, empirical analysis of real attack paths through your software. It runs inside your CI/CD pipeline and delivers continuous assurance.' },
{ title: 'Engineering', body: 'Zero-trust enforcement, key management and secure API design as modular, composable components for your codebase and deployments.' },
{ title: 'Verification', body: 'Independent review of code and security measures, hardening checklists and application-level testing across all components.' },
{ title: 'Monitoring', body: 'Intrusion detection and security logging built into the software, from the first deployment through ongoing resilience verification.' },
], ],
}, },
sectors: { sectors: {
title: 'Who we work with', title: 'Who we work with.',
items: [ items: [
{ title: 'Private 5G and campus networks', body: 'Software-defined networks with open interfaces and a multi-vendor supply chain. Our origin, and still the hardest case.', imageAlt: 'A private 5G antenna and radio cabinet on the parapet of a factory roof, cranes in the distance.' }, { title: 'Private 5G and campus networks', body: 'Software-defined networks with open interfaces and a multi-vendor supply chain. Our origin, and still the hardest case.', imageAlt: 'A private 5G antenna and radio cabinet on the parapet of a factory roof, cranes in the distance.' },
{ title: 'Critical network infrastructure', body: 'Control and network software where a breach stops production, damages equipment or endangers people.', imageAlt: 'An electrical substation seen straight from above: transformers, switchgear and a service road.' }, { title: 'Critical infrastructure', body: 'Control and network software where a breach stops production, damages equipment or endangers people.', imageAlt: 'An electrical substation seen straight from above: transformers, switchgear and a service road.' },
{ title: 'Operational technology (OT)', body: 'Controllers, SCADA and the engineering software that configures them. Systems built to run for decades, often on protocols without authentication, and rarely patched.', imageAlt: 'Three operators in hard hats at their consoles in a plant control room, facing a wall-sized diagram of the process.' },
{ title: 'Products with digital elements', body: 'Devices, firmware and installable software sold in the EU. Under the Cyber Resilience Act, the manufacturer answers for their security for the whole support period.', imageAlt: 'An assembly line in an electronics factory, device boards in a carrier on the conveyor and a worker at the line behind.' },
{ title: 'Cloud-native systems', body: 'Containers, service meshes and CI/CD pipelines that carry infrastructure workloads. Security that runs as code, alongside yours.', imageAlt: 'A cold aisle between server racks in a data centre, a technician far in the distance.' }, { title: 'Cloud-native systems', body: 'Containers, service meshes and CI/CD pipelines that carry infrastructure workloads. Security that runs as code, alongside yours.', imageAlt: 'A cold aisle between server racks in a data centre, a technician far in the distance.' },
], ],
}, },
proof: { partners: {
title: 'Where we come from.', title: 'Trusted by and working with',
items: [ roles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
{ name: 'University of Passau', body: 'Our method grew out of academic security research. The university remains our research partner.' },
{ name: 'xG-ALOE alliance and xG-Incubator', body: 'Partners in the alliance for open enterprise networks, from lab to deployment.' },
{ name: 'Federal Ministry of Research, Technology and Space', body: 'Our work is publicly funded.' },
],
}, },
quote: { quote: {
eyebrow: 'From the essay', 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.', text: 'Standard-compliant is not the same as secure.',
name: 'Dr. Felix Klement', name: 'Dr. Felix Klement',
role: 'Founder and CEO', role: 'Founder and CEO',
link: 'Read the essay',
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.', portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
}, },
{
eyebrow: 'On hardware roots of trust',
text: 'Software is only as trustworthy as the hardware it runs on.',
name: 'Dr. Florian Frank',
role: 'Founder and CTO',
portraitAlt: 'Portrait of Dr. Florian Frank, founder and CTO of CipherCell.',
},
],
},
insights: { insights: {
title: 'Insights', title: 'Insights',
all: 'All insights', all: 'All insights',
intro: 'Notes on software security in private 5G, critical networks and the systems that run them.', intro: 'Notes on software security in private 5G, critical networks and the systems that run them.',
readingTime: (min: number) => `${min} min read`, readingTime: (min: number) => `${min} min read`,
event: { category: 'Event', when: 'September 2026', text: 'Meet us at the xG‑ALOE Plugfest 2026.', link: 'Event details', href: 'https://xg-aloe.de/xg-aloe-plugfest-2026/' },
back: 'All insights', back: 'All insights',
by: 'By', by: 'By',
inEnglish: 'This essay is available in English.', inEnglish: 'This essay is available in English.',
filter: { label: 'Category', all: 'All', essay: 'Essays', linkedin: 'LinkedIn', other: 'Other' },
mention: {
category: (platform: string) => `On ${platform}`,
source: (source: string) => `Posted by ${source}`,
inEnglish: 'This post is in English.',
inGerman: 'This post is in German.',
open: (platform: string) => `Open on ${platform}`,
embedTitle: (source: string, platform: string) => `Post by ${source} on ${platform}`,
consent: (platform: string) => `The post is hosted by ${platform}. Loading it here connects your browser to ${platform}, which may set cookies.`,
load: (platform: string) => `Load post from ${platform}`,
privacy: 'Privacy policy',
},
}, },
contact: { contact: {
title: 'Talk to us.', title: 'Talk to us.',
body: 'Complex software, a tight compliance deadline, or a problem no one has solved yet. We respond within 24 hours.', body: 'Complex software, a tight deadline, or a problem no one has solved yet. We respond within 24 hours.',
address: ['Innstr. 43', '94032 Passau, Germany'], address: ['Innstr. 43', '94032 Passau, Germany'],
phone: '+49 (0)851 / 509-3218', phone: '+49 (0)851 / 509-3218',
phoneHref: 'tel:+498515093218', phoneHref: 'tel:+498515093218',
@@ -131,6 +139,15 @@ const en = {
required: 'Required.', required: 'Required.',
honeypot: 'Leave this field empty', honeypot: 'Leave this field empty',
submit: 'Send message', submit: 'Send message',
sent: 'Message sent. We respond within 24 hours.',
invalid: 'Please fill in every field and give a valid email address.',
failed: 'Your message did not go through. Please try again, or write to office@ciphercell.de.',
noscript: 'The form needs JavaScript. Without it, write to office@ciphercell.de.',
toast: { sending: 'Sending', sent: 'Sent', failed: 'Not sent', close: 'Close' },
},
result: {
sent: { title: 'Message sent.', body: 'Thank you. We respond within 24 hours.', link: 'Go to the start page' },
error: { title: 'Message not sent.', body: 'Your message did not go through. Please try again, or write to office@ciphercell.de.', link: 'Return to the form' },
}, },
}, },
footer: { footer: {
@@ -179,8 +196,8 @@ const en = {
{ {
title: 'Two stages of judgement.', title: 'Two stages of judgement.',
body: 'Polygraph reads every finding with its code, its provenance and the file around it, and returns a verdict with a certainty and a priority. A finding it is unsure about, or a false-positive call it is not certain of, goes to a second agent that can read the repository itself, within a fixed budget of requests and tokens. Every verdict carries its reasoning. A cached verdict is reused only when the code it was based on is unchanged.', body: 'Polygraph reads every finding with its code, its provenance and the file around it, and returns a verdict with a certainty and a priority. A finding it is unsure about, or a false-positive call it is not certain of, goes to a second agent that can read the repository itself, within a fixed budget of requests and tokens. Every verdict carries its reasoning. A cached verdict is reused only when the code it was based on is unchanged.',
desc: 'A finding is assembled with its context: provenance, code snippet, file header and an optional dataflow trace. A first stage returns a static verdict. Certain verdicts go straight to the result. Uncertain verdicts, and false-positive verdicts below high certainty, are escalated to a second agent that reads files, lists directories and searches the tree within a bounded budget. The result is true positive, false positive or uncertain, with certainty, priority and reasoning.', desc: 'A finding is assembled with its context: provenance, code snippet, file header and an optional dataflow trace. A static verdict agent returns a first verdict. Certain verdicts go straight to the result. Uncertain verdicts, and false-positive verdicts below high certainty, are escalated to an escalation agent that reads files, lists directories and searches the tree within a bounded budget. The result is true positive, false positive or uncertain, with certainty, priority and reasoning.',
labels: { finding: 'Finding', context: 'Context', ctx: ['Provenance', 'Code snippet', 'File header', 'Dataflow trace, optional'], stage1: ['Static', 'verdict'], stage2: 'Escalation', tools: ['Reads files', 'Lists directories', 'Searches the tree', 'Bounded budget'], verdict: 'Verdict', outcomes: ['True positive', 'False positive', 'Uncertain'], carries: 'Certainty, priority, reasoning', certain: 'Certain', unsure: 'Uncertain, or an unsure false positive' }, labels: { finding: 'Finding', context: 'Context', ctx: ['Provenance', 'Code snippet', 'File header', 'Dataflow trace, optional'], stage1: ['Static verdict', 'Agent'], stage2: ['Escalation', 'Agent'], tools: ['Reads files', 'Lists directories', 'Searches the tree', 'Bounded budget'], verdict: 'Verdict', outcomes: ['True positive', 'False positive', 'Uncertain'], carries: 'Certainty, priority, reasoning', certain: 'Certain', unsure: 'Uncertain, or an unsure false positive' },
}, },
{ {
title: 'The dependency side.', title: 'The dependency side.',
@@ -216,19 +233,125 @@ const en = {
body: 'Bring a repository you know well, and we will show you what CipherVerifier makes of it: what it finds, what it rules out, and why.', body: 'Bring a repository you know well, and we will show you what CipherVerifier makes of it: what it finds, what it rules out, and why.',
cta: 'Talk to us', cta: 'Talk to us',
}, },
soon: {
eyebrow: 'Product',
title: ['CipherVerifier.', 'Coming soon.'],
lead: 'A self-hosted security pipeline for software. Currently in a closed demonstrator phase.',
status: {
label: 'System status',
rows: [
{ key: 'Phase', value: 'Closed demonstrator' },
{ key: 'Next', value: 'Alpha release' },
{ key: 'Focus', value: 'Software security' },
],
},
notify: {
title: 'Register for the alpha.',
body: 'One email when the first alpha is released. Nothing else.',
email: 'Work email',
placeholder: 'name@company.com',
consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
privacy: 'Privacy policy',
submit: 'Register',
noscript: 'The form needs JavaScript. Without it, write to office@ciphercell.de.',
toast: { sending: 'Registering', sent: 'Link sent', failed: 'Not registered', close: 'Close' },
status: {
sent: 'Check your inbox. We sent a link to confirm your address.',
invalid: 'Please give a valid email address and agree to the notice.',
failed: 'Registration did not go through. Please try again, or write to office@ciphercell.de.',
},
pages: {
confirmed: {
title: 'Registered.',
body: 'Your address is confirmed. You will hear from us once, when the CipherVerifier alpha is released.',
link: 'Back to CipherVerifier',
seal: 'A target, locked, with a check at its centre.',
rows: [
{ key: 'Status', value: 'Confirmed' },
{ key: 'Signature', value: 'Valid' },
{ key: 'List', value: 'CipherVerifier alpha' },
{ key: 'Next email', value: 'At release' },
],
},
error: {
title: 'Not registered.',
body: 'The link has expired or is incomplete, or the registration did not go through. Register again, or write to office@ciphercell.de.',
link: 'Register again',
seal: 'A target that did not lock, with a cross at its centre.',
rows: [
{ key: 'Status', value: 'Not confirmed' },
{ key: 'Address', value: 'Not stored' },
],
},
},
},
track: {
title: 'Release path.',
body: 'Access opens in stages. Registered addresses hear first when it moves.',
steps: [
{ state: 'Now', name: 'Closed demonstrator', body: 'Running in a closed setting, by invitation only.' },
{ state: 'Next', name: 'Alpha', body: 'The first release outside the demonstrator. Registered addresses are told first.' },
{ state: 'Later', name: 'General availability', body: 'Date to follow.' },
],
},
verticals: {
title: 'Verticals.',
body: 'One pipeline underneath. Each vertical brings the knowledge of one domain to it.',
platform: 'CipherVerifier platform',
tiles: [
{
state: 'Integrated',
name: 'Telecommunication security',
title: 'Built from Open RAN security research.',
body: 'The first vertical comes from where CipherCell started: the security of Open RAN and private 5G networks, worked on with the University of Passau and the xG-ALOE alliance.',
rows: [
{ key: 'Networks', value: 'Open RAN, private 5G' },
{ key: 'Infrastructure', value: 'Critical network infrastructure' },
],
},
{
state: 'Open',
name: 'Other',
title: 'Your domain next.',
body: 'The pipeline underneath is not tied to telecommunications. Further verticals are added where operators bring a domain to us.',
cta: 'Propose a vertical',
},
],
},
},
}, },
team: { team: {
title: ['Researchers,', 'engineers,', 'architects.'], title: ['Engineers,', 'architects,', 'researchers.'],
intro: 'The people who go deep on the problems that matter, from network software and private 5G to hardware and cloud-native systems.', intro: 'The people who go deep on the problems that matter, from network, software to hardware and cloud-native systems. If it\'s a genuinely hard problem, we\'re in our element.',
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.', photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
links: { github: 'GitHub', linkedin: 'LinkedIn', email: 'Email' },
roles: { roles: {
ceo: 'Founder and CEO', ceo: 'Founder and CEO',
cto: 'Founder and CTO', cto: 'Founder and CTO',
engineer: 'Software engineer', engineer: 'Software Engineer',
bizdev: 'Business development', networkengineer: 'Network & Infrastructure Engineer',
studentF: 'Working student, development', bizdev: 'Business Development',
studentM: 'Working student, development', studentF: 'Mathematical Engineer',
studentM: 'Process Optimization & Automation',
},
research: {
title: ['Published', 'research.'],
intro: 'Expert perspectives on Open RAN security, cloud-native infrastructure, and hardware security, grounded in real-world engineering and backed by research.',
sortLabel: 'Sort',
sort: { cites: 'Most cited', newest: 'Newest', oldest: 'Oldest' },
filterLabel: 'Category',
all: 'All',
showAll: (n: number) => `Show all ${n} papers`,
showFewer: 'Show fewer',
count: (shown: number, total: number) => `Showing ${shown} of ${total} papers`,
categories: {
'Open RAN & 6G': 'Open RAN & 6G',
'Hardware security': 'Hardware security',
'Automotive & mobility': 'Automotive & mobility',
'IoT security & privacy': 'IoT security & privacy',
'Wireless security': 'Wireless security',
'Cryptography': 'Cryptography',
'AI agents': 'AI agents',
} as Record<string, string>,
}, },
}, },
notFound: { notFound: {
@@ -237,18 +360,25 @@ const en = {
link: 'Go to the start page', link: 'Go to the start page',
}, },
legal: { legal: {
imprintEyebrow: 'Information pursuant to § 5 TMG', imprintEyebrow: 'Information pursuant to § 5 DDG',
responsible: 'Responsible', asOf: 'As of 12 October 2026',
mailing: 'Mailing address', optOut: {
contact: 'Contact', checking: 'Checking whether your visits are counted.',
asOf: 'As of 14 June 2025', in: 'Your visits are counted, anonymously and without cookies.',
out: 'Your visits are not counted. To remember that, this browser keeps one cookie, mtm_consent_removed.',
dnt: 'Your browser sends Do Not Track. Your visits are not counted.',
missing: 'The statistics script is not loaded in this browser, for example because of a content blocker. Your visits are not counted.',
noscript: 'Without JavaScript the statistics script does not run. Your visits are not counted.',
stop: 'Stop counting my visits',
resume: 'Count my visits again',
},
}, },
}; };
const de: typeof en = { const de: typeof en = {
meta: { meta: {
siteName: 'CipherCell', siteName: 'CipherCell',
home: { title: 'CipherCell. Security Engineering für Software.', description: 'CipherCell ist ein Security-Engineering-Unternehmen in Passau. Bedrohungsanalyse, Security Engineering, Verifikation und Monitoring für die Software hinter privaten 5G-Netzen, kritischer Netzinfrastruktur und Cloud-nativen Systemen.' }, home: { title: 'CipherCell. Security Engineering für Software.', description: 'CipherCell ist ein Security-Engineering-Unternehmen in Passau. Sicherheit, Engineering und Forschung für die Software hinter privaten 5G-Netzen, kritischer Netzinfrastruktur und Cloud-nativen Systemen.' },
team: { title: 'Team', description: 'Die Sicherheitsforschenden, Ingenieurinnen und Architekten hinter CipherCell.' }, team: { title: 'Team', description: 'Die Sicherheitsforschenden, Ingenieurinnen und Architekten hinter CipherCell.' },
verifier: { title: 'CipherVerifier', description: 'CipherVerifier ist eine selbst gehostete Verifikations-Pipeline für Software: siebzehn statische Analysewerkzeuge und zwei SBOM-Generatoren, ein Vokabular, ein zweistufiges Urteil zu jedem Fund und eine Rangliste, die ein Engineer abarbeiten kann.' }, verifier: { title: 'CipherVerifier', description: 'CipherVerifier ist eine selbst gehostete Verifikations-Pipeline für Software: siebzehn statische Analysewerkzeuge und zwei SBOM-Generatoren, ein Vokabular, ein zweistufiges Urteil zu jedem Fund und eine Rangliste, die ein Engineer abarbeiten kann.' },
insights: { title: 'Einblicke', description: 'Notizen zur Softwaresicherheit in privaten 5G-Netzen, kritischen Netzen und den Systemen, die sie betreiben.' }, insights: { title: 'Einblicke', description: 'Notizen zur Softwaresicherheit in privaten 5G-Netzen, kritischen Netzen und den Systemen, die sie betreiben.' },
@@ -257,7 +387,6 @@ const de: typeof en = {
}, },
nav: { nav: {
skip: 'Zum Inhalt springen', skip: 'Zum Inhalt springen',
capabilities: 'Leistungen',
product: 'Produkt', product: 'Produkt',
sectors: 'Einsatzfelder', sectors: 'Einsatzfelder',
insights: 'Einblicke', insights: 'Einblicke',
@@ -268,75 +397,85 @@ const de: typeof en = {
homeLabel: 'CipherCell, Startseite', homeLabel: 'CipherCell, Startseite',
}, },
hero: { hero: {
lines: ['Sicherheit für die Software,', 'auf der alles andere läuft.'], lines: ['Sicherheit für die Software,', 'auf der alles läuft.'],
imageAlt: 'Eine Industrieanlage in der Dämmerung: Rohrbrücken, Tanks und ein Gittermast mit 5G-Antennen unter bedecktem Himmel.',
}, },
manifesto: { manifesto: {
lines: ['Sicherheit wird eingebaut,', 'nicht angeschraubt.'], lines: ['Sicherheit eingebaut,', 'nicht nachträglich angefügt.'],
p1: 'Infrastruktur ist heute Software. Private 5G-Netze, industrielle Steuerungssysteme und Cloud-Plattformen werden aus Code und Komponenten vieler Hersteller zusammengesetzt. Jede Schnittstelle dazwischen ist eine Angriffsfläche, und Standardwerkzeuge sind dafür nicht gebaut.', p1: 'Infrastruktur ist heute Software. Private 5G-Netze, industrielle Steuerungssysteme, digitale Produkte und Cloud-Plattformen setzen sich aus Code und Komponenten vieler Hersteller zusammen und jede Schnittstelle dazwischen ist eine Angriffsfläche, für die Lösungen von der Stange nie gedacht waren.',
p2: 'CipherCell ist ein Security-Engineering-Unternehmen für Software. Wir modellieren reale Angreifer, verankern Zero Trust in der Architektur statt daneben und weisen nach, dass der Code standhält. Vom ersten Bedrohungsmodell bis zum laufenden System.', p2: 'CipherCell ist ein Security-Engineering-Unternehmen von Spezialisten für Netzwerke und Software. Wir packen die schwierigen Probleme an, ganz gleich in welcher Branche, und machen Sicherheit zum Kern jeder Entscheidung.',
cta: 'Über CipherCell', cta: 'Über CipherCell',
}, },
method: { method: {
lines: ['Vier Stufen.', 'Eine Methode.'], lines: ['Drei Säulen.', 'Ein Team.'],
body: 'Bedrohungsanalyse, Engineering, Verifikation und Monitoring, auf Ihre Software angewandt als eine durchgehende Methode statt vier getrennter Projekte. Sie läuft neben Ihrer Entwicklung, modular und integrierbar.', body: ['Wir analysieren, wir entwickeln, wir forschen.', 'Drei Disziplinen, ein Expertenteam, das Hand in Hand arbeitet.'],
imageAlt: 'Eine Sicherheitsingenieurin hinter Glas, die Spiegelungen von Monitoring-Bildschirmen liegen über ihrem Gesicht.', imageAlt: 'Ein leerer Monitoring-Raum hinter Glas, Bildschirmwände und Statuslichter verschwimmen hinter den Scheiben.',
cta: 'CipherVerifier ansehen',
diagramTitle: 'Die CipherCell-Methode', diagramTitle: 'Die CipherCell-Methode',
diagramDesc: 'Ein geschlossener Kreislauf aus vier Stufen: Bedrohungsanalyse, Engineering, Verifikation und Monitoring. Jede Stufe hat ihre Instrumente: Bedrohungsmodell, Angriffspfade und CI/CD-Integration; Zero Trust, Schlüsselmanagement und sichere APIs; unabhängige Prüfung, Härtung und Tests; Angriffserkennung, Security-Logging und Resilienzprüfung. Das Monitoring fließt zurück in die Bedrohungsanalyse.', diagramDesc: 'Ein Dreieck aus drei Säulen, jede mit den beiden anderen verbunden: Sicherheit, Engineering und Forschung. Jede Säule hat ihre Instrumente: Bedrohungsmodellierung, automatisierte Bedrohungsanalyse und Sicherheitstests; sichere Architektur, FPGA- und Hardware-Design sowie Embedded- und Systementwicklung; Open-RAN- und 6G-Sicherheit, Hardware-Vertrauensanker sowie Fahrzeug- und IoT-Sicherheit. Die Forschung fließt zurück in die Sicherheit.',
stages: [ stages: [
{ title: 'Bedrohungsanalyse', items: ['Bedrohungsmodell', 'Angriffspfade', 'CI/CD-Integration'] }, { title: 'Sicherheit', items: ['Bedrohungsmodellierung', 'Automatisierte Bedrohungsanalyse', 'Sicherheitstests'] },
{ title: 'Engineering', items: ['Zero Trust', 'Schlüsselmanagement', 'Sichere APIs'] }, { title: 'Engineering', items: ['Sichere Architektur', 'FPGA- und Hardware-Design', 'Embedded- und Systementwicklung'] },
{ title: 'Verifikation', items: ['Unabhängige Prüfung', 'Härtung', 'Tests'] }, { title: 'Forschung', items: ['Open-RAN- und 6G-Sicherheit', 'Hardware-Vertrauensanker', 'Fahrzeug- und IoT-Sicherheit'] },
{ title: 'Monitoring', items: ['Angriffserkennung', 'Security-Logging', 'Resilienzprüfung'] },
],
},
stages: {
eyebrow: 'Was wir tun.',
items: [
{ title: 'Bedrohungs\u00ADanalyse', body: 'Automatisierte, empirische Analyse realer Angriffspfade durch Ihre Software. Sie läuft in Ihrer CI/CD-Pipeline und liefert kontinuierliche Absicherung.' },
{ title: 'Engineering', body: 'Zero-Trust-Durchsetzung, Schlüsselmanagement und sicheres API-Design als modulare, kombinierbare Komponenten für Ihre Codebasis und Deployments.' },
{ title: 'Verifikation', body: 'Unabhängige Prüfung von Code und Sicherheitsmaßnahmen, Härtungs-Checklisten und anwendungsnahe Tests über alle Komponenten.' },
{ title: 'Monitoring', body: 'Angriffserkennung und Security-Logging, in die Software eingebaut, vom ersten Deployment bis zur laufenden Resilienzprüfung.' },
], ],
}, },
sectors: { sectors: {
title: 'Für wen wir arbeiten', title: 'Für wen wir arbeiten.',
items: [ items: [
{ title: 'Private 5G- und Campusnetze', body: 'Softwaredefinierte Netze mit offenen Schnittstellen und einer Lieferkette aus vielen Herstellern. Unser Ursprung, und noch immer der schwerste Fall.', imageAlt: 'Eine private 5G-Antenne mit Funkschrank auf der Attika eines Fabrikdachs, Kräne im Hintergrund.' }, { title: 'Private 5G- und Campusnetze', body: 'Softwaredefinierte Netze mit offenen Schnittstellen und einer Lieferkette aus vielen Herstellern. Unser Ursprung, und noch immer der schwerste Fall.', imageAlt: 'Eine private 5G-Antenne mit Funkschrank auf der Attika eines Fabrikdachs, Kräne im Hintergrund.' },
{ title: 'Kritische Netzinfrastruktur', body: 'Steuerungs- und Netzsoftware, bei der ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' }, { title: 'Kritische Infrastruktur', body: 'Steuerungs- und Netzsoftware, bei der ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' },
{ title: 'Operational Technology (OT)', body: 'Steuerungen, SCADA und die Engineering-Software, mit der sie konfiguriert werden. Anlagen, die Jahrzehnte laufen, oft über Protokolle ohne Authentisierung, und selten Updates erhalten.', imageAlt: 'Drei Bediener mit Schutzhelm an ihren Pulten in der Leitwarte eines Werks, vor einem wandgroßen Schema der Anlage.' },
{ title: 'Produkte mit digitalen Elementen', body: 'Geräte, Firmware und installierbare Software, die in der EU verkauft werden. Nach dem Cyber Resilience Act steht der Hersteller über den gesamten Supportzeitraum für ihre Sicherheit ein.', imageAlt: 'Eine Montagelinie in einer Elektronikfabrik, Geräteplatinen in einem Träger auf dem Förderband, dahinter eine Arbeiterin an der Linie.' },
{ title: 'Cloud-native Systeme', body: 'Container, Service Meshes und CI/CD-Pipelines, die Infrastruktur tragen. Sicherheit als Code, neben Ihrem.', imageAlt: 'Ein Kaltgang zwischen Serverschränken in einem Rechenzentrum, ein Techniker weit hinten.' }, { title: 'Cloud-native Systeme', body: 'Container, Service Meshes und CI/CD-Pipelines, die Infrastruktur tragen. Sicherheit als Code, neben Ihrem.', imageAlt: 'Ein Kaltgang zwischen Serverschränken in einem Rechenzentrum, ein Techniker weit hinten.' },
], ],
}, },
proof: { partners: {
title: 'Woher wir kommen.', title: 'Vertrauen und Zusammenarbeit',
items: [ roles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
{ name: 'Universität Passau', body: 'Unsere Methode ist aus akademischer Sicherheitsforschung entstanden. Die Universität bleibt unser Forschungspartner.' },
{ name: 'xG-ALOE-Allianz und xG-Incubator', body: 'Partner in der Allianz für offene Unternehmensnetze, vom Labor bis zum Betrieb.' },
{ name: 'Bundesministerium für Forschung, Technologie und Raumfahrt', body: 'Unsere Arbeit wird öffentlich gefördert.' },
],
}, },
quote: { quote: {
eyebrow: 'Aus dem Essay', 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.', text: 'Standardkonform ist nicht dasselbe wie sicher.',
name: 'Dr. Felix Klement', name: 'Dr. Felix Klement',
role: 'Gründer und CEO', role: 'Gründer und CEO',
link: 'Essay lesen',
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.', portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
}, },
{
eyebrow: 'Über Vertrauensanker in der Hardware',
text: 'Software ist nur so vertrauenswürdig wie die Hardware, auf der sie läuft.',
name: 'Dr. Florian Frank',
role: 'Gründer und CTO',
portraitAlt: 'Porträt von Dr. Florian Frank, Gründer und CTO von CipherCell.',
},
],
},
insights: { insights: {
title: 'Einblicke', title: 'Einblicke',
all: 'Alle Einblicke', all: 'Alle Einblicke',
intro: 'Notizen zur Softwaresicherheit in privaten 5G-Netzen, kritischen Netzen und den Systemen, die sie betreiben.', intro: 'Notizen zur Softwaresicherheit in privaten 5G-Netzen, kritischen Netzen und den Systemen, die sie betreiben.',
readingTime: (min: number) => `${min} Min. Lesezeit`, readingTime: (min: number) => `${min} Min. Lesezeit`,
event: { category: 'Veranstaltung', when: 'September 2026', text: 'Treffen Sie uns beim xG‑ALOE Plugfest 2026.', link: 'Zur Veranstaltung', href: 'https://xg-aloe.de/xg-aloe-plugfest-2026/' },
back: 'Alle Einblicke', back: 'Alle Einblicke',
by: 'Von', by: 'Von',
inEnglish: 'Dieser Essay liegt auf Englisch vor.', inEnglish: 'Dieser Essay liegt auf Englisch vor.',
filter: { label: 'Kategorie', all: 'Alle', essay: 'Essays', linkedin: 'LinkedIn', other: 'Weitere' },
mention: {
category: (platform: string) => `Auf ${platform}`,
source: (source: string) => `Veröffentlicht von ${source}`,
inEnglish: 'Dieser Beitrag ist auf Englisch.',
inGerman: 'Dieser Beitrag ist auf Deutsch.',
open: (platform: string) => `Auf ${platform} öffnen`,
embedTitle: (source: string, platform: string) => `Beitrag von ${source} auf ${platform}`,
consent: (platform: string) => `Der Beitrag liegt bei ${platform}. Wenn Sie ihn hier laden, verbindet sich Ihr Browser mit ${platform}; dabei können Cookies gesetzt werden.`,
load: (platform: string) => `Beitrag von ${platform} laden`,
privacy: 'Datenschutzerklärung',
},
}, },
contact: { contact: {
title: 'Sprechen Sie mit uns.', title: 'Sprechen Sie mit uns.',
body: 'Komplexe Software, eine knappe Compliance-Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.', body: 'Komplexe Software, eine knappe Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.',
address: ['Innstr. 43', '94032 Passau, Deutschland'], address: ['Innstr. 43', '94032 Passau, Deutschland'],
phone: '+49 (0)851 / 509-3218', phone: '+49 (0)851 / 509-3218',
phoneHref: 'tel:+498515093218', phoneHref: 'tel:+498515093218',
@@ -349,6 +488,15 @@ const de: typeof en = {
required: 'Pflichtfeld.', required: 'Pflichtfeld.',
honeypot: 'Dieses Feld bitte leer lassen', honeypot: 'Dieses Feld bitte leer lassen',
submit: 'Nachricht senden', submit: 'Nachricht senden',
sent: 'Nachricht gesendet. Wir antworten innerhalb von 24 Stunden.',
invalid: 'Bitte füllen Sie alle Felder aus und geben Sie eine gültige E-Mail-Adresse an.',
failed: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
noscript: 'Das Formular braucht JavaScript. Ohne schreiben Sie an office@ciphercell.de.',
toast: { sending: 'Wird gesendet', sent: 'Gesendet', failed: 'Nicht gesendet', close: 'Schließen' },
},
result: {
sent: { title: 'Nachricht gesendet.', body: 'Vielen Dank. Wir antworten innerhalb von 24 Stunden.', link: 'Zur Startseite' },
error: { title: 'Nachricht nicht gesendet.', body: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.', link: 'Zurück zum Formular' },
}, },
}, },
footer: { footer: {
@@ -397,8 +545,8 @@ const de: typeof en = {
{ {
title: 'Urteil in zwei Stufen.', title: 'Urteil in zwei Stufen.',
body: 'Polygraph liest jeden Fund mit seinem Code, seiner Herkunft und der Datei darum herum und gibt ein Urteil mit Sicherheit und Priorität zurück. Ein Fund, bei dem es unsicher ist, oder ein Fehlalarm-Urteil, dessen es sich nicht sicher ist, geht an einen zweiten Agenten, der das Repository selbst lesen kann, innerhalb eines festen Budgets an Anfragen und Tokens. Jedes Urteil trägt seine Begründung. Ein gemerktes Urteil wird nur wiederverwendet, wenn der Code, auf dem es beruht, unverändert ist.', body: 'Polygraph liest jeden Fund mit seinem Code, seiner Herkunft und der Datei darum herum und gibt ein Urteil mit Sicherheit und Priorität zurück. Ein Fund, bei dem es unsicher ist, oder ein Fehlalarm-Urteil, dessen es sich nicht sicher ist, geht an einen zweiten Agenten, der das Repository selbst lesen kann, innerhalb eines festen Budgets an Anfragen und Tokens. Jedes Urteil trägt seine Begründung. Ein gemerktes Urteil wird nur wiederverwendet, wenn der Code, auf dem es beruht, unverändert ist.',
desc: 'Ein Fund wird mit seinem Kontext zusammengestellt: Herkunft, Code-Ausschnitt, Dateikopf und optional eine Datenfluss-Spur. Eine erste Stufe liefert ein statisches Urteil. Sichere Urteile gehen direkt ins Ergebnis. Unsichere Urteile und Fehlalarm-Urteile unterhalb hoher Sicherheit werden an einen zweiten Agenten eskaliert, der Dateien liest, Verzeichnisse auflistet und den Baum durchsucht, innerhalb eines begrenzten Budgets. Das Ergebnis ist echt, Fehlalarm oder unsicher, mit Sicherheit, Priorität und Begründung.', desc: 'Ein Fund wird mit seinem Kontext zusammengestellt: Herkunft, Code-Ausschnitt, Dateikopf und optional eine Datenfluss-Spur. Ein Agent für statische Urteile liefert ein erstes Urteil. Sichere Urteile gehen direkt ins Ergebnis. Unsichere Urteile und Fehlalarm-Urteile unterhalb hoher Sicherheit werden an einen Eskalations-Agenten weitergegeben, der Dateien liest, Verzeichnisse auflistet und den Baum durchsucht, innerhalb eines begrenzten Budgets. Das Ergebnis ist echt, Fehlalarm oder unsicher, mit Sicherheit, Priorität und Begründung.',
labels: { finding: 'Fund', context: 'Kontext', ctx: ['Herkunft', 'Code-Ausschnitt', 'Dateikopf', 'Datenfluss-Spur, optional'], stage1: ['Statisches', 'Urteil'], stage2: 'Eskalation', tools: ['Liest Dateien', 'Listet Verzeichnisse', 'Durchsucht den Baum', 'Begrenztes Budget'], verdict: 'Urteil', outcomes: ['Echt', 'Fehlalarm', 'Unsicher'], carries: 'Sicherheit, Priorität, Begründung', certain: 'Sicher', unsure: 'Unsicher oder unsicherer Fehlalarm' }, labels: { finding: 'Fund', context: 'Kontext', ctx: ['Herkunft', 'Code-Ausschnitt', 'Dateikopf', 'Datenfluss-Spur, optional'], stage1: ['Agent für', 'statische Urteile'], stage2: ['Eskalations-', 'Agent'], tools: ['Liest Dateien', 'Listet Verzeichnisse', 'Durchsucht den Baum', 'Begrenztes Budget'], verdict: 'Urteil', outcomes: ['Echt', 'Fehlalarm', 'Unsicher'], carries: 'Sicherheit, Priorität, Begründung', certain: 'Sicher', unsure: 'Unsicher oder unsicherer Fehlalarm' },
}, },
{ {
title: 'Die Seite der Abhängigkeiten.', title: 'Die Seite der Abhängigkeiten.',
@@ -434,19 +582,125 @@ const de: typeof en = {
body: 'Bringen Sie ein Repository mit, das Sie gut kennen, und wir zeigen Ihnen, was CipherVerifier daraus macht: was es findet, was es ausschließt, und warum.', body: 'Bringen Sie ein Repository mit, das Sie gut kennen, und wir zeigen Ihnen, was CipherVerifier daraus macht: was es findet, was es ausschließt, und warum.',
cta: 'Sprechen Sie mit uns', cta: 'Sprechen Sie mit uns',
}, },
soon: {
eyebrow: 'Produkt',
title: ['CipherVerifier.', 'Demnächst.'],
lead: 'Eine selbst gehostete Security-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
status: {
label: 'Systemstatus',
rows: [
{ key: 'Phase', value: 'Geschlossener Demonstrator' },
{ key: 'Als Nächstes', value: 'Alpha-Release' },
{ key: 'Fokus', value: 'Software sicherheit' },
],
},
notify: {
title: 'Für die Alpha registrieren.',
body: 'Eine E-Mail, sobald die erste Alpha erscheint. Sonst nichts.',
email: 'Geschäftliche E-Mail',
placeholder: 'name@firma.de',
consent: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
privacy: 'Datenschutzerklärung',
submit: 'Registrieren',
noscript: 'Das Formular braucht JavaScript. Ohne schreiben Sie an office@ciphercell.de.',
toast: { sending: 'Wird registriert', sent: 'Link gesendet', failed: 'Nicht registriert', close: 'Schließen' },
status: {
sent: 'Bitte sehen Sie in Ihr Postfach. Wir haben Ihnen einen Link zur Bestätigung geschickt.',
invalid: 'Bitte geben Sie eine gültige E-Mail-Adresse an und stimmen Sie dem Hinweis zu.',
failed: 'Die Registrierung ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
},
pages: {
confirmed: {
title: 'Registriert.',
body: 'Ihre Adresse ist bestätigt. Sie hören einmal von uns, sobald die Alpha von CipherVerifier erscheint.',
link: 'Zurück zu CipherVerifier',
seal: 'Ein Zielkreuz, eingerastet, mit einem Haken in der Mitte.',
rows: [
{ key: 'Status', value: 'Bestätigt' },
{ key: 'Signatur', value: 'Gültig' },
{ key: 'Liste', value: 'CipherVerifier-Alpha' },
{ key: 'Nächste E-Mail', value: 'Zur Veröffentlichung' },
],
},
error: {
title: 'Nicht registriert.',
body: 'Der Link ist abgelaufen oder unvollständig, oder die Registrierung ist nicht angekommen. Registrieren Sie sich erneut oder schreiben Sie an office@ciphercell.de.',
link: 'Erneut registrieren',
seal: 'Ein Zielkreuz, nicht eingerastet, mit einem Kreuz in der Mitte.',
rows: [
{ key: 'Status', value: 'Nicht bestätigt' },
{ key: 'Adresse', value: 'Nicht gespeichert' },
],
},
},
},
track: {
title: 'Weg zum Release.',
body: 'Der Zugang öffnet sich in Stufen. Registrierte Adressen erfahren zuerst, wenn es weitergeht.',
steps: [
{ state: 'Jetzt', name: 'Geschlossener Demonstrator', body: 'Läuft in einem geschlossenen Rahmen, nur auf Einladung.' },
{ state: 'Als Nächstes', name: 'Alpha', body: 'Das erste Release außerhalb des Demonstrators. Registrierte Adressen erfahren es zuerst.' },
{ state: 'Später', name: 'Allgemeine Verfügbarkeit', body: 'Termin folgt.' },
],
},
verticals: {
title: 'Vertikalen.',
body: 'Darunter eine Pipeline. Jede Vertikale bringt das Wissen einer Domäne mit.',
platform: 'Plattform CipherVerifier',
tiles: [
{
state: 'Integriert',
name: 'Telekommunikations\u00ADsicherheit',
title: 'Aus der Open-RAN-Sicherheitsforschung.',
body: 'Die erste Vertikale kommt von dort, wo CipherCell begonnen hat: der Sicherheit von Open-RAN- und privaten 5G-Netzen, erarbeitet mit der Universität Passau und der xG-ALOE-Allianz.',
rows: [
{ key: 'Netze', value: 'Open RAN, private 5G-Netze' },
{ key: 'Infrastruktur', value: 'Kritische Netzinfrastruktur' },
],
},
{
state: 'Offen',
name: 'Weitere',
title: 'Ihre Domäne als Nächstes.',
body: 'Die Pipeline darunter ist nicht an die Telekommunikation gebunden. Weitere Vertikalen entstehen dort, wo Betreiber uns eine Domäne bringen.',
cta: 'Vertikale vorschlagen',
},
],
},
},
}, },
team: { team: {
title: ['Forschende,', 'Ingenieure,', 'Architekten.'], title: ['Forschende,', 'Ingenieure,', 'Architekten.'],
intro: 'Die Menschen, die sich in die Probleme vertiefen, auf die es ankommt: von Netzsoftware und privaten 5G-Netzen bis zu Hardware und Cloud-nativen Systemen.', intro: 'Die Menschen, die sich in die Probleme vertiefen, auf die es ankommt: von Netzwerken über Software bis hin zu Hardware und Cloud-nativen Systemen. Wenn es sich um ein wirklich schwieriges Problem handelt, sind wir in unserem Element.',
photoAlt: 'Das CipherCell-Team auf einer Treppe in den Büros in Passau.', photoAlt: 'Das CipherCell-Team auf einer Treppe in den Büros in Passau.',
links: { github: 'GitHub', linkedin: 'LinkedIn', email: 'E-Mail' },
roles: { roles: {
ceo: 'Gründer und CEO', ceo: 'Gründer und CEO',
cto: 'Gründer und CTO', cto: 'Gründer und CTO',
engineer: 'Software Engineer', engineer: 'Software Engineer',
networkengineer: 'Network & Infrastructure Engineer',
bizdev: 'Business Development', bizdev: 'Business Development',
studentF: 'Werkstudentin, Entwicklung', studentF: 'Mathematical Engineer',
studentM: 'Werkstudent, Entwicklung', studentM: 'Process Optimization & Automation',
},
research: {
title: ['Veröffentlichte', 'Forschung.'],
intro: 'Experteneinblicke in Open-RAN-Sicherheit, Cloud-native Infrastruktur und Hardware-Sicherheit, basierend auf praxiserprobter Ingenieursarbeit und wissenschaftlich fundiert.',
sortLabel: 'Sortieren',
sort: { cites: 'Meistzitiert', newest: 'Neueste', oldest: 'Älteste' },
filterLabel: 'Kategorie',
all: 'Alle',
showAll: (n: number) => `Alle ${n} Arbeiten zeigen`,
showFewer: 'Weniger zeigen',
count: (shown: number, total: number) => `${shown} von ${total} Arbeiten`,
categories: {
'Open RAN & 6G': 'Open RAN & 6G',
'Hardware security': 'Hardware-Sicherheit',
'Automotive & mobility': 'Automotive & Mobilität',
'IoT security & privacy': 'IoT-Sicherheit & Datenschutz',
'Wireless security': 'Funksicherheit',
'Cryptography': 'Kryptografie',
'AI agents': 'KI-Agenten',
} as Record<string, string>,
}, },
}, },
notFound: { notFound: {
@@ -455,11 +709,18 @@ const de: typeof en = {
link: 'Zur Startseite', link: 'Zur Startseite',
}, },
legal: { legal: {
imprintEyebrow: 'Angaben gemäß § 5 TMG', imprintEyebrow: 'Angaben gemäß § 5 DDG',
responsible: 'Verantwortlich', asOf: 'Stand: 12. Oktober 2026',
mailing: 'Postanschrift', optOut: {
contact: 'Kontakt', checking: 'Es wird geprüft, ob Ihre Besuche gezählt werden.',
asOf: 'Stand: 14. Juni 2025', in: 'Ihre Besuche werden gezählt, anonymisiert und ohne Cookies.',
out: 'Ihre Besuche werden nicht gezählt. Damit das so bleibt, speichert dieser Browser ein Cookie: mtm_consent_removed.',
dnt: 'Ihr Browser sendet „Do Not Track“. Ihre Besuche werden nicht gezählt.',
missing: 'Das Statistik-Skript ist in diesem Browser nicht geladen, etwa wegen eines Inhaltsblockers. Ihre Besuche werden nicht gezählt.',
noscript: 'Ohne JavaScript läuft das Statistik-Skript nicht. Ihre Besuche werden nicht gezählt.',
stop: 'Besuche nicht mehr zählen',
resume: 'Besuche wieder zählen',
},
}, },
}; };
+16 -5
View File
@@ -2,6 +2,7 @@
import '../styles/base.css'; import '../styles/base.css';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import Matomo from '../components/Matomo.astro';
import { langFromUrl, switchLang, t, type Lang } from '../i18n/ui'; import { langFromUrl, switchLang, t, type Lang } from '../i18n/ui';
interface Props { interface Props {
@@ -9,10 +10,15 @@ interface Props {
description: string; description: string;
lang?: Lang; lang?: Lang;
ogImage?: string; ogImage?: string;
/** Pages that only make sense as the answer to an action, such as the contact form result. */
noindex?: boolean;
/** The surface the page opens on. 'night' for pages that start on a dark hero, so the header
is drawn in its night tone from the first frame instead of switching after load. */
tone?: 'paper' | 'night';
} }
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url); const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
const { title, description } = Astro.props; const { title, description, noindex, tone = 'paper' } = Astro.props;
const ui = t(lang); const ui = t(lang);
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`; const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
const canonical = new URL(Astro.url.pathname, Astro.site); const canonical = new URL(Astro.url.pathname, Astro.site);
@@ -23,16 +29,20 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
<html lang={lang}> <html lang={lang}>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{fullTitle}</title> <title>{fullTitle}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
{noindex && <meta name="robots" content="noindex" />}
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<meta name="theme-color" content="#E9E5DF" /> <!-- Tints the browser chrome (the iOS status bar) to match the surface the page opens on:
--night for a dark hero, --paper otherwise. The header script updates it on scroll. -->
<meta name="theme-color" content={tone === 'night' ? '#0E100F' : '#E9E5DF'} />
<link rel="canonical" href={canonical} /> <link rel="canonical" href={canonical} />
<link rel="alternate" hreflang="en" href={altEn} /> <link rel="alternate" hreflang="en" href={altEn} />
<link rel="alternate" hreflang="de" href={altDe} /> <link rel="alternate" hreflang="de" href={altDe} />
<link rel="alternate" hreflang="x-default" href={altEn} /> <link rel="alternate" hreflang="x-default" href={altEn} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content={ui.meta.siteName} /> <meta property="og:site_name" content={ui.meta.siteName} />
@@ -42,10 +52,11 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
<meta property="og:locale" content={lang === 'de' ? 'de_DE' : 'en_GB'} /> <meta property="og:locale" content={lang === 'de' ? 'de_DE' : 'en_GB'} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<script is:inline>document.documentElement.classList.add('js');</script> <script is:inline>document.documentElement.classList.add('js');</script>
<Matomo />
</head> </head>
<body> <body>
<a class="skip-link" href="#main">{ui.nav.skip}</a> <a class="skip-link" href="#main">{ui.nav.skip}</a>
<Header lang={lang} /> <Header lang={lang} tone={tone} />
<main id="main" tabindex="-1"> <main id="main" tabindex="-1">
<slot /> <slot />
</main> </main>
+29
View File
@@ -0,0 +1,29 @@
---
import Base from '../layouts/Base.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; outcome: 'sent' | 'error' }
const { lang, outcome } = Astro.props;
const r = t(lang).contact.result[outcome];
// After an error the way forward is the form again; after sending it is the rest of the site.
const href = outcome === 'sent' ? localePath(lang, '/') : `${localePath(lang, '/')}#contact`;
---
<Base lang={lang} title={r.title} description={r.body} noindex>
<section class="section">
<div class="container grid">
<h1 class="display result__title">{r.title}</h1>
<div class="result__body">
<p class="muted">{r.body}</p>
<a class="link" href={href}>{r.link}</a>
</div>
</div>
</section>
</Base>
<style>
.result__title { grid-column: 1 / -1; }
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); }
@media (min-width: 48rem) {
.result__title { grid-column: 1 / span 8; }
.result__body { grid-column: 1 / span 5; }
}
</style>
+3 -5
View File
@@ -3,9 +3,8 @@ import Base from '../layouts/Base.astro';
import Hero from '../components/home/Hero.astro'; import Hero from '../components/home/Hero.astro';
import Manifesto from '../components/home/Manifesto.astro'; import Manifesto from '../components/home/Manifesto.astro';
import Method from '../components/home/Method.astro'; import Method from '../components/home/Method.astro';
import Stages from '../components/home/Stages.astro';
import Sectors from '../components/home/Sectors.astro'; import Sectors from '../components/home/Sectors.astro';
import Proof from '../components/home/Proof.astro'; import Partners from '../components/home/Partners.astro';
import Quote from '../components/home/Quote.astro'; import Quote from '../components/home/Quote.astro';
import Insights from '../components/home/Insights.astro'; import Insights from '../components/home/Insights.astro';
import Contact from '../components/home/Contact.astro'; import Contact from '../components/home/Contact.astro';
@@ -15,13 +14,12 @@ interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
--- ---
<Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description}> <Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description} tone="night">
<Hero lang={lang} /> <Hero lang={lang} />
<Manifesto lang={lang} /> <Manifesto lang={lang} />
<Method lang={lang} /> <Method lang={lang} />
<Stages lang={lang} />
<Sectors lang={lang} /> <Sectors lang={lang} />
<Proof lang={lang} /> <Partners lang={lang} />
<Quote lang={lang} /> <Quote lang={lang} />
<Insights lang={lang} /> <Insights lang={lang} />
<Contact lang={lang} /> <Contact lang={lang} />
+96 -17
View File
@@ -1,32 +1,56 @@
--- ---
import { getCollection } from 'astro:content'; import { feedType, feedTypes, getFeed } from '../data/insights';
import { Picture } from 'astro:assets'; import { Picture } from 'astro:assets';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import Field from '../components/Field.astro';
import { formatDate, localePath, t, type Lang } from '../i18n/ui'; import { formatDate, localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
const posts = (await getCollection('insights')).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); const feed = await getFeed();
const m = ui.insights.mention;
const f = ui.insights.filter;
/* Only filters that have entries; "All" first. */
const filters = [
{ key: 'all', label: f.all, count: feed.length },
...feedTypes
.map((key) => ({ key, label: f[key], count: feed.filter((item) => feedType(item) === key).length }))
.filter((x) => x.count > 0),
];
--- ---
<Base lang={lang} title={ui.meta.insights.title} description={ui.meta.insights.description}> <Base lang={lang} title={ui.meta.insights.title} description={ui.meta.insights.description}>
<section class="section section--intro"> <section class="section section--intro">
<div class="container grid"> <div class="container grid">
<h1 class="display index__title">{ui.insights.title}</h1> <h1 class="display index__title load-rise" style="--i: 0">{ui.insights.title}</h1>
<p class="index__lead">{ui.insights.intro}</p> <p class="index__lead load-rise" style="--i: 1">{ui.insights.intro}</p>
</div> </div>
</section> </section>
<section class="section--list" aria-label={ui.insights.title}> <section class="section--list" aria-label={ui.insights.title}>
<div class="container"> <div class="container load-rise" style="--i: 2">
<ul class="posts" role="list"> {filters.length > 2 && (
{posts.map((post) => ( <fieldset class="choice-group filters" data-filters>
<li class="post"> <legend class="choice-group__legend">{f.label}</legend>
<a class="post__link" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}> <div class="choices">
{filters.map((x) => (
<label class="choice">
<input type="radio" name="insights-type" value={x.key} checked={x.key === 'all'} />
<span>{x.label} <span class="choice__n">{x.count}</span></span>
</label>
))}
</div>
</fieldset>
)}
<ul class="posts" role="list" data-feed>
{feed.map((item) => item.kind === 'post' ? (
<li class="post" data-type={feedType(item)}>
<a class="post__link" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
{item.entry.data.cover ? (
<div class="post__media"> <div class="post__media">
<Picture <Picture
src={post.data.cover} src={item.entry.data.cover}
alt={post.data.coverAlt} alt={item.entry.data.coverAlt!}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[400, 640, 960]} widths={[400, 640, 960]}
@@ -36,15 +60,39 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
class="post__img img-doc" class="post__img img-doc"
/> />
</div> </div>
) : (
<Field seed={item.entry.id} class="post__media post__field">
<span class="field__title" aria-hidden="true">{item.entry.data.category}</span>
</Field>
)}
<div class="post__text"> <div class="post__text">
<p class="small muted post__meta"> <p class="small muted post__meta">
<span>{post.data.category}</span> <span>{item.entry.data.category}</span>
<time datetime={post.data.date.toISOString().slice(0, 10)}>{formatDate(post.data.date, lang)}</time> <time datetime={item.date.toISOString().slice(0, 10)}>{formatDate(item.date, lang)}</time>
<span>{ui.insights.readingTime(post.data.readingTime)}</span> <span>{ui.insights.readingTime(item.entry.data.readingTime)}</span>
</p> </p>
<h2 class="h2">{post.data.title}</h2> <h2 class="h2">{item.entry.data.title}</h2>
{post.data.subtitle && <p class="muted">{post.data.subtitle}</p>} {item.entry.data.subtitle && <p class="muted">{item.entry.data.subtitle}</p>}
{post.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>} {item.entry.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>}
</div>
</a>
</li>
) : (
<li class="post" data-type={feedType(item)}>
<a class="post__link" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
<Field seed={item.entry.id} class="post__media post__field">
<span class="field__title" aria-hidden="true">{item.entry.data.source}</span>
<span class="field__meta" aria-hidden="true">{item.entry.data.platform}</span>
</Field>
<div class="post__text">
<p class="small muted post__meta">
<span>{m.category(item.entry.data.platform)}</span>
<time datetime={item.date.toISOString().slice(0, 10)}>{formatDate(item.date, lang)}</time>
<span>{m.source(item.entry.data.source)}</span>
</p>
<h2 class="h2">{item.entry.data.title}</h2>
<p class="muted mention__summary">{item.entry.data.summary}</p>
{item.entry.data.lang !== lang && <p class="small muted">{item.entry.data.lang === 'en' ? m.inEnglish : m.inGerman}</p>}
</div> </div>
</a> </a>
</li> </li>
@@ -54,6 +102,29 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
</section> </section>
</Base> </Base>
<script>
/* Filter the list by type. The choice lives in ?type= so a filtered view can be linked;
without script every entry shows. */
const bar = document.querySelector<HTMLElement>('[data-filters]');
const items = document.querySelectorAll<HTMLElement>('[data-feed] > li');
if (bar) {
const inputs = bar.querySelectorAll<HTMLInputElement>('input[name="insights-type"]');
const keys = [...inputs].map((i) => i.value);
const apply = (key: string, push: boolean) => {
if (!keys.includes(key)) key = 'all';
inputs.forEach((i) => { i.checked = i.value === key; });
items.forEach((li) => { li.hidden = key !== 'all' && li.dataset.type !== key; });
if (push) {
const url = new URL(location.href);
if (key === 'all') url.searchParams.delete('type'); else url.searchParams.set('type', key);
history.replaceState(null, '', url);
}
};
bar.addEventListener('change', (e) => apply((e.target as HTMLInputElement).value, true));
apply(new URL(location.href).searchParams.get('type') ?? 'all', false);
}
</script>
<style> <style>
.index__title { grid-column: 1 / -1; } .index__title { grid-column: 1 / -1; }
.index__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); } .index__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
@@ -62,6 +133,9 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
.index__lead { grid-column: 1 / span 5; } .index__lead { grid-column: 1 / span 5; }
} }
.section--list { padding-bottom: var(--section); } .section--list { padding-bottom: var(--section); }
/* Filter row: the shared segmented choice (base.css), as on the research list. */
.filters { padding-bottom: var(--space-6); }
.post[hidden] { display: none; }
.posts { border-top: var(--hairline) solid var(--line); } .posts { border-top: var(--hairline) solid var(--line); }
.post { border-bottom: var(--hairline) solid var(--line); } .post { border-bottom: var(--hairline) solid var(--line); }
.post__link { .post__link {
@@ -74,6 +148,11 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
.post__img, :global(.post__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; } .post__img, :global(.post__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }
.post__text { display: grid; gap: var(--space-4); align-content: start; } .post__text { display: grid; gap: var(--space-4); align-content: start; }
.post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); } .post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
/* No cover (every mention, an essay without one): a colour field names who posted it and where. */
:global(.post__field) { aspect-ratio: 3 / 2; gap: var(--space-2); }
:global(.post__field) .field__title { font-size: var(--step-title); line-height: var(--leading-title); letter-spacing: var(--track-h2); }
:global(.post__field) .field__meta { font-size: var(--step-small); line-height: var(--leading-small); }
.mention__summary { max-width: var(--measure); }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.post__link { grid-template-columns: 5fr 7fr; } .post__link { grid-template-columns: 5fr 7fr; }
} }
+109
View File
@@ -0,0 +1,109 @@
---
import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content';
import Base from '../layouts/Base.astro';
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; mention: CollectionEntry<'mentions'> }
const { lang, mention } = Astro.props;
const ui = t(lang);
const m = ui.insights.mention;
const d = mention.data;
/* Optional notes of our own below the front matter. */
const hasBody = Boolean(mention.body?.trim());
const { Content } = await render(mention);
---
<Base lang={lang} title={d.title} description={d.summary}>
<article class="mention">
<header class="section section--intro">
<div class="container grid">
<div class="mention__back">
<a class="link" href={localePath(lang, '/insights')}>{ui.insights.back}</a>
</div>
<div class="mention__head">
<p class="small muted mention__meta">
<span>{m.category(d.platform)}</span>
<time datetime={d.date.toISOString().slice(0, 10)}>{formatDate(d.date, lang)}</time>
<span>{m.source(d.source)}</span>
</p>
<h1 class="h2 mention__title" lang={d.lang !== lang ? d.lang : undefined}>{d.title}</h1>
<p class="mention__summary muted" lang={d.lang !== lang ? d.lang : undefined}>{d.summary}</p>
{hasBody && <div class="prose mention__notes" lang={d.lang !== lang ? d.lang : undefined}><Content /></div>}
<p><a class="link" href={d.url} rel="noopener">{m.open(d.platform)}</a></p>
</div>
</div>
</header>
{d.embed && (
<section class="mention__embed-section" aria-label={m.embedTitle(d.source, d.platform)}>
<div class="container grid">
<div class="embed hud-frame" data-embed data-src={d.embed} data-title={m.embedTitle(d.source, d.platform)} style={`--embed-h: ${d.embedHeight}px`}>
<div class="embed__gate" data-embed-gate>
<p class="embed__source">{d.source}</p>
<p class="small muted embed__consent">
{m.consent(d.platform)} <a href={localePath(lang, '/privacy')}>{m.privacy}</a>
</p>
<button class="button" type="button" data-embed-load>{m.load(d.platform)}</button>
</div>
</div>
</div>
</section>
)}
</article>
</Base>
<script>
/* Two-click embed: nothing is requested from the platform until the visitor asks for it. */
document.querySelectorAll<HTMLElement>('[data-embed]').forEach((box) => {
const button = box.querySelector<HTMLButtonElement>('[data-embed-load]');
button?.addEventListener('click', () => {
const frame = document.createElement('iframe');
frame.src = box.dataset.src!;
frame.title = box.dataset.title ?? '';
frame.loading = 'lazy';
frame.className = 'embed__frame';
frame.setAttribute('allowfullscreen', '');
box.querySelector('[data-embed-gate]')?.remove();
box.append(frame);
box.toggleAttribute('data-loaded', true);
frame.focus();
});
});
</script>
<style>
.mention__back { grid-column: 1 / -1; }
.mention__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); justify-items: start; }
.mention__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
.mention__title { text-wrap: balance; }
.mention__summary { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); max-width: var(--measure); }
.mention__embed-section { padding-bottom: var(--section); }
.embed {
grid-column: 1 / -1;
display: grid;
aspect-ratio: 4 / 3;
background: var(--paper-deep);
}
/* Loaded: the frame takes the post's height. The jump answers the visitor's own click. */
.embed[data-loaded] { aspect-ratio: auto; }
.embed[data-loaded] { background: var(--paper); }
.embed__gate {
display: grid;
align-content: end;
justify-items: start;
gap: var(--space-5);
padding: var(--space-6);
}
.embed__source { font-size: var(--step-title); line-height: var(--leading-title); letter-spacing: var(--track-h2); color: var(--ink-muted); }
.embed__consent { max-width: 34em; }
.embed__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
.embed :global(.embed__frame) { width: 100%; height: var(--embed-h); border: 0; display: block; }
@media (min-width: 48rem) {
.mention__back { grid-column: 1 / span 2; }
.mention__head { grid-column: 3 / span 8; }
/* Half the grid, centred: columns 4 to 9. */
.embed { grid-column: 4 / span 6; }
}
</style>
+82
View File
@@ -0,0 +1,82 @@
---
import Base from '../layouts/Base.astro';
import Seal from '../components/verifier/Seal.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; page: 'confirmed' | 'error' }
const { lang, page } = Astro.props;
const p = t(lang).verifier.soon.notify.pages;
const r = p[page];
const product = localePath(lang, '/cipherverifier');
---
<Base lang={lang} title={r.title} description={r.body} noindex>
<section class="section result" aria-labelledby="result-title">
<div class="container result__inner">
<div class="result__head">
<h1 id="result-title" class="display result__title">{r.title}</h1>
<div class="result__seal">
<Seal state={page} label={r.seal} />
</div>
</div>
<div class="result__text">
<p class="result__body">{r.body}</p>
<a class="button" href={page === 'error' ? `${product}#notify` : product}>{r.link}</a>
</div>
<dl class="result__readout hud-frame" aria-label={r.title}>
{r.rows.map((row) => (
<div class="result__cell">
<dt>{row.key}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
</div>
</section>
</Base>
<style>
.result__inner { display: grid; gap: var(--space-7); }
/* The seal sits beside the headline as a mark, about the height of its cap line. */
/* On narrow screens it stands above the headline; from 64rem it follows it on the same line. */
.result__head { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-5); }
.result__seal { order: -1; width: 4.5rem; flex: none; }
.result__text { display: grid; gap: var(--space-5); justify-items: start; }
.result__body { max-width: var(--measure); color: var(--ink-muted); }
.result__readout {
display: grid;
font-family: var(--font-mono);
font-size: var(--step-micro);
letter-spacing: var(--track-caps);
text-transform: uppercase;
line-height: var(--leading-small);
}
.result__cell {
display: grid;
grid-template-columns: 9rem 1fr;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.result__cell + .result__cell { border-top: var(--hairline) solid var(--line-base); }
.result__readout dt { color: var(--ink-muted); }
/* The text arrives as the seal locks; the readout last. */
.result__text, .result__readout { animation: result-in var(--dur-rise) var(--ease) both; }
.result__text { animation-delay: 500ms; }
.result__readout { animation-delay: 1000ms; }
@keyframes result-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.result__text, .result__readout { animation: none; }
}
@media (min-width: 64rem) {
.result__inner { grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gap); row-gap: var(--space-7); }
.result__head { grid-column: 1 / -1; flex-direction: row; align-items: center; gap: var(--space-6); }
.result__seal { order: 0; width: 7.5rem; }
.result__text { grid-column: 1 / span 6; }
.result__readout { grid-column: 1 / -1; grid-auto-flow: column; grid-auto-columns: 1fr; }
.result__cell { grid-template-columns: 1fr; gap: var(--space-1); padding: var(--space-4) var(--space-5); }
.result__cell + .result__cell { border-top: 0; border-left: var(--hairline) solid var(--line-base); }
}
</style>
+3 -3
View File
@@ -31,10 +31,10 @@ const d = post.data;
</div> </div>
</header> </header>
<div class="container"> {d.cover && <div class="container">
<Picture <Picture
src={d.cover} src={d.cover}
alt={d.coverAlt} alt={d.coverAlt!}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[640, 960, 1440, 1920]} widths={[640, 960, 1440, 1920]}
@@ -43,7 +43,7 @@ const d = post.data;
decoding="async" decoding="async"
class="post__cover img-doc" class="post__cover img-doc"
/> />
</div> </div>}
<div class="section container grid"> <div class="section container grid">
<div class="prose post__body"> <div class="prose post__body">
+29 -24
View File
@@ -3,6 +3,7 @@ import { Picture } from 'astro:assets';
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import teamPhoto from '../assets/images/team.png'; import teamPhoto from '../assets/images/team.png';
import { team } from '../data/team'; import { team } from '../data/team';
import Research from '../components/team/Research.astro';
import { t, type Lang } from '../i18n/ui'; import { t, type Lang } from '../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
@@ -13,26 +14,27 @@ const ui = t(lang);
<section class="section section--intro team-intro"> <section class="section section--intro team-intro">
<div class="container grid"> <div class="container grid">
<h1 class="display team-intro__title"> <h1 class="display team-intro__title">
{ui.team.title.map((line) => <span class="line">{line}</span>)} {ui.team.title.map((line, i) => <span class="line load-rise" style={`--i: ${i}`}>{line}</span>)}
</h1> </h1>
<p class="team-intro__lead">{ui.team.intro}</p> <p class="team-intro__lead load-rise" style={`--i: ${ui.team.title.length}`}>{ui.team.intro}</p>
</div> </div>
</section> </section>
<div class="team-photo"> <div class="container">
<figure class="team-photo">
<Picture <Picture
src={teamPhoto} src={teamPhoto}
alt={ui.team.photoAlt} alt={ui.team.photoAlt}
formats={['avif']} formats={['avif']}
fallbackFormat="webp" fallbackFormat="webp"
widths={[640, 960, 1440]} widths={[640, 960, 1296, 1920, 2592]}
sizes="100vw" sizes="(min-width: 90rem) 81rem, calc(100vw - 2 * clamp(1.25rem, 5vw, 5rem))"
loading="eager" loading="eager"
decoding="async" decoding="async"
class="img-cover img-mono team-photo__img" class="img-mono team-photo__img"
pictureAttributes={{ class: 'team-photo__picture' }} pictureAttributes={{ class: 'team-photo__picture' }}
/> />
<div class="team-photo__overlay" aria-hidden="true"></div> </figure>
</div> </div>
<section class="section" aria-label={ui.nav.team}> <section class="section" aria-label={ui.nav.team}>
@@ -53,20 +55,30 @@ const ui = t(lang);
/> />
<h2 class="h3 person__name">{p.name}</h2> <h2 class="h3 person__name">{p.name}</h2>
<p class="muted">{ui.team.roles[p.role]}</p> <p class="muted">{ui.team.roles[p.role]}</p>
<ul class="person__links small" role="list">
{p.github && <li><a href={p.github} rel="noopener">{ui.team.links.github}</a></li>}
{p.linkedin && <li><a href={p.linkedin} rel="noopener">{ui.team.links.linkedin}</a></li>}
<li><a href={`mailto:${p.email}`}>{ui.team.links.email}</a></li>
</ul>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
</section> </section>
<Research lang={lang} />
</Base> </Base>
<style> <style>
.line { display: block; } .line { display: block; }
/* Load: the headline rises line by line and the lead follows (.load-rise, base.css);
the photo settles from a slight scale. Then stillness. */
.team-photo { overflow: hidden; }
.team-photo__img, :global(.team-photo__img) { animation: team-settle var(--dur-hero) var(--ease) both; }
@keyframes team-settle {
from { transform: scale(1.045); }
to { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.team-photo__img, :global(.team-photo__img) { animation: none; transform: none; }
}
.team-intro__title { grid-column: 1 / -1; } .team-intro__title { grid-column: 1 / -1; }
.team-intro__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); } .team-intro__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
@media (min-width: 48rem) { @media (min-width: 48rem) {
@@ -74,16 +86,11 @@ const ui = t(lang);
.team-intro__lead { grid-column: 9 / span 4; align-self: end; } .team-intro__lead { grid-column: 9 / span 4; align-self: end; }
} }
.team-photo { /* Inside the content column at the photo's own proportions: no crop, no zoom, so every pixel
position: relative; the browser shows exists in the source. Widths go to 2592 for retina screens. */
/* Explicit height: an aspect-ratio with max-height would transfer the cap to the width. */ .team-photo { margin: 0; }
height: min(66.67vw, 80svh); :global(.team-photo__picture) { display: block; }
min-height: 20rem; .team-photo__img, :global(.team-photo__img) { display: block; width: 100%; height: auto; }
overflow: hidden;
}
:global(.team-photo__picture) { position: absolute; inset: 0; }
.team-photo__img, :global(.team-photo__img) { width: 100%; height: 100%; object-fit: cover; object-position: 50% 30%; }
.team-photo__overlay { position: absolute; inset: 0; background: var(--overlay); opacity: 0.6; }
.people { .people {
display: grid; display: grid;
@@ -91,8 +98,6 @@ const ui = t(lang);
} }
.person { display: grid; gap: var(--space-2); align-content: start; } .person { display: grid; gap: var(--space-2); align-content: start; }
.person__img, :global(.person__img) { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; margin-bottom: var(--space-2); } .person__img, :global(.person__img) { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; margin-bottom: var(--space-2); }
.person__links { display: flex; gap: var(--space-4); margin-top: var(--space-2); }
.person__links a { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
@media (min-width: 30rem) { .people { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (min-width: 30rem) { .people { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .people { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (min-width: 64rem) { .people { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+13 -1
View File
@@ -6,17 +6,29 @@ import HowItWorks from '../components/verifier/HowItWorks.astro';
import Cards from '../components/verifier/Cards.astro'; import Cards from '../components/verifier/Cards.astro';
import Facts from '../components/verifier/Facts.astro'; import Facts from '../components/verifier/Facts.astro';
import Outro from '../components/verifier/Outro.astro'; import Outro from '../components/verifier/Outro.astro';
import SoonHero from '../components/verifier/SoonHero.astro';
import ComingSoon from '../components/verifier/ComingSoon.astro';
import { verifierComingSoon } from '../data/flags';
import { t, type Lang } from '../i18n/ui'; import { t, type Lang } from '../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
--- ---
<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} />
<ComingSoon lang={lang} />
</>
) : (
<>
<Hero lang={lang} /> <Hero lang={lang} />
<Intro lang={lang} /> <Intro lang={lang} />
<HowItWorks lang={lang} /> <HowItWorks lang={lang} />
<Cards lang={lang} /> <Cards lang={lang} />
<Facts lang={lang} /> <Facts lang={lang} />
<Outro lang={lang} /> <Outro lang={lang} />
</>
)}
</Base> </Base>
+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" />
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more