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
66 changed files with 2082 additions and 1164 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
+5
View File
@@ -9,3 +9,8 @@ CONTACT_TO=office@ciphercell.de
# start makes its own. Generate with: openssl rand -hex 32 # start makes its own. Generate with: openssl rand -hex 32
ALTCHA_HMAC_SECRET= ALTCHA_HMAC_SECRET=
ALTCHA_HMAC_KEY_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
+1
View File
@@ -14,3 +14,4 @@ ds-bundle/
.env .env
.env.* .env.*
!.env.example !.env.example
__pycache__/
+2 -1
View File
@@ -10,12 +10,13 @@ RUN npm ci --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
# The contact handler's production dependencies (nodemailer only). # The form handler and its production dependencies (nodemailer, altcha-lib).
FROM node:22-alpine AS contact FROM node:22-alpine AS contact
WORKDIR /srv/contact WORKDIR /srv/contact
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund RUN npm ci --omit=dev --no-audit --no-fund
COPY server/contact.mjs ./ COPY server/contact.mjs ./
COPY server/mail ./mail
# Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000. # Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000.
FROM nginx:1.27-alpine AS runtime FROM nginx:1.27-alpine AS runtime
+60 -51
View File
@@ -38,18 +38,18 @@ Environment; see *Contact form* below.
| `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/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. |
| `server/contact.mjs` | The contact form handler: validates, rate-limits and sends by SMTP. Its own `package.json`, so the image only carries nodemailer. | | `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: `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`. | | `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
@@ -59,7 +59,7 @@ Environment; see *Contact form* below.
## Things the host has to provide ## Things the host has to provide
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a - **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs` honeypot field `website` to `POST /contact`. That path is handled by `server/contact.mjs`
(Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as (Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
Reply-To. The form sends in place and shows the result in a toast; a post that asks for HTML Reply-To. The form sends in place and shows the result in a toast; a post that asks for HTML
is redirected to `/contact/sent` or `/contact/error` (under `/de` for German). Both pages are is redirected to `/contact/sent` or `/contact/error` (under `/de` for German). Both pages are
@@ -74,7 +74,7 @@ Environment; see *Contact form* below.
and no third party is involved, so there is nothing to consent to. The form therefore needs 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. JavaScript; without it a `<noscript>` line points to the email address.
In the container, nginx proxies `/contact` and `/contact/challenge` to the handler on `127.0.0.1:3000`; both are started 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, 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 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. rest of the site is served as usual.
@@ -88,20 +88,31 @@ Environment; see *Contact form* below.
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. | | `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). | | `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. | | `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. | | `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 In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact` and `.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies the same paths to it.
`/contact/challenge` to it.
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier` - **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`, shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is `list=cipherverifier-alpha`, the honeypot `website` and the proof-of-work answer `altcha` to
server-side and not part of this static build. It should store the address with the time of `POST /notify`, handled by `server/contact.mjs` like the contact form. It is double opt-in
consent, confirm it by double opt-in, and redirect back with a thank-you. The privacy page without a database: the handler emails the address a link to `GET /notify/verify` carrying a
promises deletion after the alpha mail is sent. Set the flag to false to bring back the full signed token (address, language, time of consent; valid 7 days) and keeps nothing. Opening the
product page. link confirms: the handler sends the confirmed registration, with the consent wording and both
- **Matomo.** The privacy page links to the existing opt-out on `cipher.uber.space`. The tracking times, to `CONTACT_TO`, and redirects to `/notify/confirmed`. A mail scanner that opens every
snippet itself is not included; add it in `src/layouts/Base.astro` if analytics should continue. 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 ## Insights filter
@@ -140,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.
@@ -215,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
+2 -2
View File
@@ -5,7 +5,7 @@ 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({ filter: (page) => !/\/contact\//.test(page), 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'],
@@ -13,5 +13,5 @@ export default defineConfig({
}, },
build: { inlineStylesheets: 'auto' }, build: { inlineStylesheets: 'auto' },
// In development the form posts to the contact handler started with `npm run contact`. // In development the form posts to the contact handler started with `npm run contact`.
vite: { server: { proxy: { '^/contact(/challenge)?$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } }, vite: { server: { proxy: { '^/(contact(/challenge)?|notify(/verify)?)(\\?|$)': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
}); });
+28
View File
@@ -51,6 +51,34 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 10s; 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. # Handler not running: send the visitor to the error page in their language.
location @contact_down { location @contact_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; } if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; }
+172 -166
View File
@@ -8,7 +8,6 @@
"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", "altcha-lib": "^2.6.0",
"astro": "^7.3.3", "astro": "^7.3.3",
"sharp": "^0.35.4" "sharp": "^0.35.4"
@@ -700,7 +699,6 @@
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.3", "@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
@@ -712,7 +710,6 @@
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -1143,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",
@@ -1162,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"
], ],
@@ -1180,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"
], ],
@@ -1202,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"
@@ -1225,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"
], ],
@@ -1241,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"
], ],
@@ -1257,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": [
@@ -1273,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": [
@@ -1289,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": [
@@ -1305,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": [
@@ -1321,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": [
@@ -1337,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": [
@@ -1353,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": [
@@ -1369,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": [
@@ -1385,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": [
@@ -1403,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": [
@@ -1425,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": [
@@ -1447,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": [
@@ -1469,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": [
@@ -1491,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": [
@@ -1513,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": [
@@ -1535,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": [
@@ -1557,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": {
@@ -1577,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"
@@ -1596,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"
], ],
@@ -1615,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"
], ],
@@ -1634,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"
], ],
@@ -2098,7 +2134,6 @@
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@@ -2236,7 +2271,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2938,7 +2972,6 @@
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3970,7 +4003,6 @@
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4126,16 +4158,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.9" "@rolldown/binding-win32-x64-msvc": "1.2.9"
} }
}, },
"node_modules/rou3": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-1.0.0.tgz",
"integrity": "sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/satteri": { "node_modules/satteri": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@@ -4181,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",
@@ -4197,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": {
@@ -4275,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"
@@ -4287,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"
@@ -4305,19 +4327,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/srvx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-1.0.5.tgz",
"integrity": "sha512-KvSKRpgPG/oaq3cyT614OQ2bAa7DynuGamivCZeoZzIUULOdbQz6wUtnnVuC4+DCx2Zglzo8v5gBYmWIWYhDxA==",
"license": "MIT",
"optional": true,
"bin": {
"srvx": "bin/srvx.mjs"
},
"engines": {
"node": ">=20.16.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -4470,7 +4479,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4779,7 +4787,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
@@ -5212,7 +5219,6 @@
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"devOptional": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
-1
View File
@@ -12,7 +12,6 @@
"contact": "node --env-file-if-exists=.env server/contact.mjs" "contact": "node --env-file-if-exists=.env server/contact.mjs"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0",
"altcha-lib": "^2.6.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.
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();
-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);
+169 -37
View File
@@ -1,20 +1,28 @@
/** /**
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static. * The handler for the site's two forms; nginx proxies their paths here and everything else stays
* static. Both send by SMTP.
* *
* The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the * 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 * 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 * 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). * 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 * A post without a solved challenge is refused, so the form needs scripting; the address next
* to it does not. * 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. * Configuration comes from the environment only, see README.md.
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto'; import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { createChallenge, randomInt, verifySolution } from 'altcha-lib'; import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
import { confirmMail } from './mail/confirm.mjs';
const env = process.env; const env = process.env;
const PORT = Number(env.CONTACT_PORT || 3000); const PORT = Number(env.CONTACT_PORT || 3000);
@@ -22,6 +30,8 @@ const HOST = env.CONTACT_HOST || '127.0.0.1';
const TO = env.CONTACT_TO || 'office@ciphercell.de'; const TO = env.CONTACT_TO || 'office@ciphercell.de';
const FROM = env.CONTACT_FROM || env.SMTP_USER; const FROM = env.CONTACT_FROM || env.SMTP_USER;
const SMTP_PORT = Number(env.SMTP_PORT || 587); 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) { if (!env.SMTP_HOST || !FROM) {
console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.'); console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.');
@@ -32,6 +42,9 @@ if (!env.SMTP_HOST || !FROM) {
restart if the visitor fetched a new one, which the form does after every send. */ 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_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex');
const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_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({ const transport = nodemailer.createTransport({
host: env.SMTP_HOST, host: env.SMTP_HOST,
@@ -153,55 +166,68 @@ function readBody(req) {
}); });
} }
function reply(req, res, lang, outcome) { /* 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]; const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, failed: 502 }[outcome];
if ((req.headers.accept || '').includes('application/json')) { if ((req.headers.accept || '').includes('application/json')) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ ok: outcome === 'sent', outcome })); res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
return; return;
} }
const page = outcome === 'sent' ? 'sent' : 'error'; redirect(res, lang, outcome === 'sent' ? pages.sent : pages.error);
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' }); }
function redirect(res, lang, path) {
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}${path}`, 'Cache-Control': 'no-store' });
res.end(); res.end();
} }
const server = createServer(async (req, res) => { async function contact(req, res, form, lang) {
const path = (req.url || '').split('?')[0];
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 (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
// A cross-site post is not a visitor using the form.
const origin = req.headers.origin;
const host = req.headers['x-forwarded-host'] || req.headers.host;
if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; }
let form;
try {
form = new URLSearchParams(await readBody(req));
} catch {
res.writeHead(413).end();
return;
}
const lang = form.get('lang') === 'de' ? 'de' : 'en';
// Filled honeypot: answer as if sent, so the bot learns nothing.
if (form.get('website')) { reply(req, res, lang, 'sent'); return; }
const data = validate(form); const data = validate(form);
if (!data) { reply(req, res, lang, 'invalid'); return; } if (!data) { reply(req, res, lang, 'invalid'); return; }
// No solved challenge: a script, or a browser without scripting. Checked before the rate // 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. // 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 (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; }
if (limited(clientIp(req))) { reply(req, res, lang, 'limited'); return; }
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown';
if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; }
const name = `${data.first_name} ${data.last_name}`; const name = `${data.first_name} ${data.last_name}`;
try { try {
@@ -226,6 +252,112 @@ const server = createServer(async (req, res) => {
console.error('contact: sending failed:', err.message); console.error('contact: sending failed:', err.message);
reply(req, res, lang, 'failed'); 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}`)); 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

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

+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,
+9 -1
View File
@@ -7,7 +7,6 @@ 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: 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 },
@@ -251,6 +250,14 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
/* Tone: watch a one-pixel line through the middle of the bar and switch to night while a /* 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. */ .night section crosses it. An IntersectionObserver, so nothing runs on scroll. */
const sections = document.querySelectorAll<HTMLElement>('.night'); 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). // 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'))); const settle = () => requestAnimationFrame(() => requestAnimationFrame(() => header?.removeAttribute('data-tone-init')));
if (header && sections.length && 'IntersectionObserver' in window) { if (header && sections.length && 'IntersectionObserver' in window) {
@@ -265,6 +272,7 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target); if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target);
} }
header.dataset.tone = under.size ? 'night' : 'paper'; header.dataset.tone = under.size ? 'night' : 'paper';
chrome(header.dataset.tone);
if (header.hasAttribute('data-tone-init')) settle(); if (header.hasAttribute('data-tone-init')) settle();
}, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` }); }, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` });
sections.forEach((section) => observer!.observe(section)); sections.forEach((section) => observer!.observe(section));
+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>
+7 -196
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;
@@ -18,6 +19,7 @@ const f = ui.contact.form;
<form <form
class="contact__form" class="contact__form"
id="contact-form"
action="/contact" action="/contact"
method="post" method="post"
lang={lang} lang={lang}
@@ -55,21 +57,7 @@ const f = ui.contact.form;
</form> </form>
</div> </div>
<!-- The form's answer, as a toast in the lower right. Framed like the menu, on night. --> <Toast for="contact-form" labels={{ ...f.toast }} />
<div
class="toast hud-frame"
data-toast
data-label-sending={f.toast.sending}
data-label-sent={f.toast.sent}
data-label-failed={f.toast.failed}
>
<div class="toast__head">
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
<button class="toast__close" type="button" data-toast-close>{f.toast.close}</button>
</div>
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
</div>
</section> </section>
<style> <style>
@@ -118,105 +106,6 @@ const f = ui.contact.form;
.contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; } .contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; }
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
reach out onto the page, so they take the menu's line colour, not the night one. */
.toast {
--frame-color: var(--hud-line);
position: fixed;
right: var(--gutter);
bottom: var(--gutter);
z-index: 20;
width: min(26rem, calc(100% - 2 * var(--gutter)));
display: grid;
gap: var(--space-3);
padding: var(--space-4) var(--space-5) var(--space-5);
background: var(--night);
color: var(--ink-on-night);
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
just sinks back, quicker and without the overshoot. */
opacity: 0;
visibility: hidden;
transform: translateY(var(--space-5)) scale(0.94);
transform-origin: bottom right;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease),
visibility 0s linear var(--dur-fast);
}
.toast[data-open] {
opacity: 1;
visibility: visible;
transform: none;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-slow) var(--ease-pop);
}
.toast__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toast__label,
.toast__close {
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
}
.toast__label { color: var(--ink-on-night); }
/* While sending, three dots after the label light up one after another. */
.toast__dots { display: none; }
.toast[data-state='sending'] .toast__dots { display: inline; }
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
.toast__close {
color: var(--ink-muted-on-night);
padding: var(--space-2);
margin: calc(-1 * var(--space-2));
transition: color var(--dur-fast) var(--ease);
}
.toast__close:hover { color: var(--ink-on-night); }
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
.toast__message {
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
to read. Pointer or focus pauses it. */
.toast__timer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--hud-line-width);
background: var(--ink-muted-on-night);
transform-origin: left;
transform: scaleX(0);
}
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
.toast:hover .toast__timer,
.toast:focus-within .toast__timer { animation-play-state: paused; }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* One shared cycle in four beats: none, one, two, three dots. */
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-open] { transition: none; transform: none; }
.toast__dots span { animation: none; }
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
.toast__timer { visibility: hidden; }
}
@media (min-width: 48rem) { @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; }
@@ -228,87 +117,9 @@ const f = ui.contact.form;
</style> </style>
<script> <script>
// The form sends in place and answers in a toast. Every send carries the answer to a // Sends in place and answers in the toast; see scripts/send-form.ts. Without scripting the
// proof-of-work challenge (ALTCHA), worked out in the background from the moment the visitor // server refuses the post and redirects to /contact/error, which points to the email address.
// first touches the form. Without scripting the server refuses the post and redirects to import { sendInPlace } from '../../scripts/send-form';
// /contact/error, which points to the email address.
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
const form = document.querySelector<HTMLFormElement>('[data-contact]'); const form = document.querySelector<HTMLFormElement>('[data-contact]');
const toast = document.querySelector<HTMLElement>('[data-toast]'); if (form) sendInPlace(form);
if (form && toast) {
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
// One challenge per message: fetched and solved once, spent by the send that carries it.
let proof: Promise<string | null> | null = null;
const solve = async (): Promise<string | null> => {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
};
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
const altcha = await prepare();
proof = null;
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
});
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}
</script> </script>
+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>
+79 -85
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)} />
<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) => (
<g>
<circle cx={n.x} cy={n.y} r={ring} stroke-dasharray="2 5" />
{stages[i].items.map((_, k) => {
const sx = n.x + n.side * W.sat;
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 fill="currentColor">
{wideNodes.map((n, i) => (
<g>
<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 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> </g>
{wideNodes.map((n, i) => (
<g class="diagram__pillar">
{stages[i].items.map((item, k) => (
<g>
<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" />
<circle cx={n.x + n.side * W.sat} cy={n.y + (k - 1) * W.dy} r="3" class="diagram__dot" />
<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>
</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" />
<circle cx={n.x} cy={n.y} r={ring + 8} class="diagram__hit" />
<circle cx={n.x} cy={n.y} r="5" fill="currentColor" />
<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>
</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>
<g class="diagram__labels" font-size="16" font-weight="500"> <g class="diagram__label" font-size={C.label} font-weight="500" text-anchor="middle">
{tallNodes.map((n, i) => <text x={n.x + T.labelX} y={n.y + 6}>{stages[i].title}</text>)} {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>
<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>
@@ -11,28 +11,18 @@ const ui = t(lang);
/* The partner logos as on ciphercell.de (see PartnerLogo.astro for the two states). `h` balances /* 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. */ optical size: the incubator wordmark is long and thin, the other two carry a mark. */
const roles = ui.proof.partnerRoles; const roles = ui.partners.roles;
const logos = await Promise.all([ const logos = await Promise.all([
{ name: 'xG-Incubator', role: roles.incubator, src: incubator, h: 'var(--space-5)' }, { 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: '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)' }, { 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 }))); ].map(async (l) => ({ ...l, url: (await getImage({ src: l.src, format: 'webp' })).src })));
--- ---
<section class="section proof" aria-labelledby="proof-title"> <section class="section section--tight partners" aria-labelledby="partners-title">
<div class="container grid"> <div class="container grid">
<h2 id="proof-title" class="h2 proof__title">{ui.proof.title}</h2> <div class="partners__block">
<ul class="proof__list" role="list"> <h2 class="small muted partners__title" id="partners-title">{ui.partners.title}</h2>
{ui.proof.items.map((p) => ( <ul class="partners__list" role="list">
<li class="proof__item">
<p class="proof__name">{p.name}</p>
<p class="muted proof__body">{p.body}</p>
</li>
))}
</ul>
<div class="partners">
<p class="small muted" id="partners-label">{ui.proof.partners}</p>
<ul class="partners__list" role="list" aria-labelledby="partners-label">
{logos.map((l) => ( {logos.map((l) => (
<li class="partners__item" data-partner> <li class="partners__item" data-partner>
<PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} /> <PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} />
@@ -48,18 +38,8 @@ const logos = await Promise.all([
</section> </section>
<style> <style>
.proof { border-top: var(--hairline) solid var(--line); } .partners__title { font-weight: var(--weight-regular); }
.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; }
/* Hover answers the pointer with the logo's own ground: night, original colours. */ /* 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); } [data-partner] { transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
@@ -68,9 +48,8 @@ const logos = await Promise.all([
} }
@media (prefers-reduced-motion: reduce) { [data-partner] { transition: none; } } @media (prefers-reduced-motion: reduce) { [data-partner] { transition: none; } }
.partners { .partners__block {
grid-column: 1 / -1; grid-column: 1 / -1;
margin-top: var(--space-9);
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
} }
@@ -115,9 +94,6 @@ const logos = await Promise.all([
} }
@media (min-width: 48rem) { @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); }
.partners__list { grid-template-columns: repeat(3, minmax(0, 1fr)); } .partners__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.partners__item + .partners__item { border-top: 0; border-left: var(--hairline) solid var(--line); } .partners__item + .partners__item { border-top: 0; border-left: var(--hairline) solid var(--line); }
} }
+17 -5
View File
@@ -16,7 +16,7 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
--- ---
<section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes> <section class="section--tight quote" aria-roledescription="carousel" aria-label={ui.quote.label} data-quotes>
<div class="container"> <div class="container">
<div class="quote__frame"> <div class="quote__frame" data-frame>
<div class="quote__panel"> <div class="quote__panel">
<div class="quote__stack" aria-live="off" data-live> <div class="quote__stack" aria-live="off" data-live>
{items.map((item, i) => ( {items.map((item, i) => (
@@ -85,6 +85,8 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
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);
@@ -223,7 +225,7 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
<script> <script>
/** /**
* Rotates the founders' statements. Plays on its own only without reduced motion; holds while * Rotates the founders' statements. Plays on its own only without reduced motion; holds while
* the pointer or focus is inside or the section is off screen; picking a statement stops it. * keyboard focus is inside or the section is off screen; picking a statement stops it.
*/ */
const root = document.querySelector<HTMLElement>('[data-quotes]'); const root = document.querySelector<HTMLElement>('[data-quotes]');
if (root) { if (root) {
@@ -232,6 +234,7 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
const picks = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-pick]')); const picks = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-pick]'));
const live = root.querySelector<HTMLElement>('[data-live]')!; const live = root.querySelector<HTMLElement>('[data-live]')!;
const controls = root.querySelector<HTMLElement>('[data-controls]')!; 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 fills = picks.map((p) => p.querySelector<HTMLElement>('.quote__fill')!);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let index = 0; let index = 0;
@@ -258,16 +261,25 @@ const quoted = (text: string) => (lang === 'de' ? `„${text}“` : `“${text}
fills.forEach((fill) => fill.addEventListener('animationend', () => { if (playing) show(index + 1); })); fills.forEach((fill) => fill.addEventListener('animationend', () => { if (playing) show(index + 1); }));
picks.forEach((pick, i) => pick.addEventListener('click', () => { setPlaying(false); show(i); })); 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); const hold = (on: boolean) => root.toggleAttribute('data-held', on);
root.addEventListener('pointerenter', () => hold(true));
root.addEventListener('pointerleave', () => hold(root.contains(document.activeElement)));
root.addEventListener('focusin', () => hold(true)); root.addEventListener('focusin', () => hold(true));
root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(root.matches(':hover')); }); 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. // 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); new IntersectionObserver(([entry]) => root.toggleAttribute('data-away', !entry.isIntersecting), { threshold: 0.4 }).observe(root);
controls.hidden = false; controls.hidden = false;
root.setAttribute('data-ready', '');
setPlaying(!reduced); setPlaying(!reduced);
} }
</script> </script>
+76 -13
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>
@@ -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; }
@@ -110,19 +144,44 @@ const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id
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);
} }
@@ -132,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-offset) + 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>
+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;
+2 -50
View File
@@ -1,13 +1,10 @@
--- ---
import Schematic from './Schematic.astro'; import Verticals from './Verticals.astro';
import { overview } from './diagrams';
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 s = ui.verifier.soon; const s = ui.verifier.soon;
const g = ui.verifier.how.groups[0];
const pair = overview(g);
/* The step that is current; earlier ones read as passed, later ones as open. */ /* The step that is current; earlier ones read as passed, later ones as open. */
const now = 0; const now = 0;
--- ---
@@ -30,30 +27,7 @@ const now = 0;
</div> </div>
</section> </section>
<section class="section spec" aria-labelledby="spec-title"> <Verticals lang={lang} />
<div class="container grid">
<div class="spec__head">
<h2 id="spec-title" class="h2">{s.spec.title}</h2>
<p class="muted">{s.spec.body}</p>
</div>
<dl class="spec__table">
{s.spec.rows.map((r) => (
<div class="spec__row">
<dt>{r.key}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
<figure class="spec__figure">
<div class="spec__drawing hud-frame">
<div class="spec__wide"><Schematic layout={pair.wide} title={g.title} desc={g.desc} variant="wide" id="soon-wide" /></div>
<div class="spec__tall"><Schematic layout={pair.tall} title={g.title} desc={g.desc} variant="tall" id="soon-tall" /></div>
</div>
<figcaption class="small muted">{s.spec.figure}</figcaption>
</figure>
</div>
</section>
<style> <style>
/* Release path: one hairline, one node per phase. */ /* 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 { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
@@ -100,30 +74,8 @@ const now = 0;
.track__step[data-state='next'] .track__name { color: var(--ink-muted); } .track__step[data-state='next'] .track__name { color: var(--ink-muted); }
.track__body { max-width: 22em; } .track__body { max-width: 22em; }
/* Spec sheet: key and value on hairlines, like a data sheet. */
.spec { border-top: var(--hairline) solid var(--line); }
.spec__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
.spec__head p { max-width: 28em; }
.spec__table { grid-column: 1 / -1; border-top: var(--hairline) solid var(--ink); }
.spec__row {
display: grid;
grid-template-columns: minmax(8rem, 1fr) 2fr;
gap: var(--gap);
padding-block: var(--space-4);
border-bottom: var(--hairline) solid var(--line);
}
.spec__row dt { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); padding-top: 0.15em; }
.spec__figure { grid-column: 1 / -1; display: grid; gap: var(--space-3); margin-top: var(--space-6); }
.spec__drawing { padding: var(--space-6); }
.spec__wide { display: none; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.track__head { grid-column: 1 / span 4; } .track__head { grid-column: 1 / span 4; }
.spec__head { grid-column: 1 / span 5; }
.spec__table { grid-column: 7 / span 6; }
.spec__wide { display: block; }
.spec__tall { display: none; }
.spec__drawing { padding: var(--space-7) var(--space-8); }
} }
/* Wide: the rail turns horizontal and runs across all three phases. */ /* Wide: the rail turns horizontal and runs across all three phases. */
@media (min-width: 64rem) { @media (min-width: 64rem) {
@@ -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>
+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>
+26 -20
View File
@@ -1,4 +1,5 @@
--- ---
import Toast from '../Toast.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;
@@ -59,7 +60,18 @@ const ridges = Array.from({ length: rows }, (_, r) => {
<p class="shero__lead">{s.lead}</p> <p class="shero__lead">{s.lead}</p>
</div> </div>
<form class="signup hud-frame" id="notify" action="/notify" method="post" lang={lang} aria-labelledby="notify-title"> <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"> <div class="signup__head">
<h2 id="notify-title" class="signup__title">{n.title}</h2> <h2 id="notify-title" class="signup__title">{n.title}</h2>
<p class="signup__body">{n.body}</p> <p class="signup__body">{n.body}</p>
@@ -79,6 +91,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
<label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label> <label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label>
</div> </div>
<button class="button signup__submit" type="submit">{n.submit}</button> <button class="button signup__submit" type="submit">{n.submit}</button>
<noscript><p class="signup__status">{n.noscript}</p></noscript>
</form> </form>
<!-- Status strip: the menu bar's HUD language, set along the foot of the hero. --> <!-- Status strip: the menu bar's HUD language, set along the foot of the hero. -->
@@ -87,11 +100,13 @@ const ridges = Array.from({ length: rows }, (_, r) => {
{s.status.rows.map((r) => ( {s.status.rows.map((r) => (
<div class="readout__cell"> <div class="readout__cell">
<dt>{r.key}</dt> <dt>{r.key}</dt>
<dd>{'live' in r && r.live && <span class="readout__live" aria-hidden="true" />}{r.value}</dd> <dd>{r.value}</dd>
</div> </div>
))} ))}
</dl> </dl>
</div> </div>
<Toast for="notify" labels={n.toast} />
</section> </section>
<style> <style>
@@ -199,6 +214,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
color: var(--ink-muted); color: var(--ink-muted);
} }
.signup__consent input { width: 1.1em; height: 1.1em; margin: 0.15em 0 0; accent-color: var(--paper); } .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; } .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: stacked on small screens, one strip of cells on wide ones. */
@@ -220,24 +236,6 @@ const ridges = Array.from({ length: rows }, (_, r) => {
} }
.readout__cell + .readout__cell { border-top: var(--hairline) solid var(--line-on-night); } .readout__cell + .readout__cell { border-top: var(--hairline) solid var(--line-on-night); }
.readout__cell--head, .readout dt { color: var(--ink-muted); } .readout__cell--head, .readout dt { color: var(--ink-muted); }
.readout dd { display: flex; align-items: center; gap: var(--space-2); }
.readout__live { position: relative; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--signal); flex: none; }
/* The live phase pulses: a ring in the signal tone grows out of the dot and fades. */
.readout__live::after {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--signal);
animation: readout-pulse calc(6 * var(--dur-slow)) var(--ease) infinite;
}
@keyframes readout-pulse {
from { transform: scale(1); opacity: 0.7; }
70%, to { transform: scale(3); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.readout__live::after { animation: none; opacity: 0; }
}
@media (max-width: 30rem) { @media (max-width: 30rem) {
.signup { padding: var(--space-5); } .signup { padding: var(--space-5); }
@@ -262,3 +260,11 @@ const ridges = Array.from({ length: rows }, (_, r) => {
.readout__cell--head { place-content: center; text-align: center; } .readout__cell--head { place-content: center; text-align: center; }
} }
</style> </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>
+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,
};
+2 -2
View File
@@ -8,7 +8,7 @@ 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;
@@ -20,7 +20,7 @@ export const team: Person[] = [
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix }, { name: 'Dr. Felix Klement', role: 'ceo', portrait: felix },
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian }, { name: 'Dr. Florian Frank', role: 'cto', portrait: florian },
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander }, { name: 'Alexander Braml', role: 'engineer', portrait: alexander },
{ name: 'Erik Foris', role: 'engineer', portrait: erik }, { name: 'Erik Foris', role: 'networkengineer', portrait: erik },
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc }, { name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc },
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp }, { name: 'Philipp Mayr', role: 'engineer', portrait: philipp },
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin }, { name: 'Karin Kaufmann', role: 'studentF', portrait: karin },
+179 -114
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,54 +47,40 @@ 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.'],
}, },
manifesto: { manifesto: {
lines: ['Security 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 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.' },
],
partners: 'Trusted by and working with',
partnerRoles: { incubator: 'Incubator', aloe: 'Alliance for open enterprise networks', passau: 'Research partner' },
}, },
quote: { quote: {
label: 'Statements from the founders', label: 'Statements from the founders',
@@ -253,13 +236,13 @@ const en = {
soon: { soon: {
eyebrow: 'Product', eyebrow: 'Product',
title: ['CipherVerifier.', 'Coming soon.'], title: ['CipherVerifier.', 'Coming soon.'],
lead: 'A self-hosted verification pipeline for software. Currently in a closed demonstrator phase.', lead: 'A self-hosted security pipeline for software. Currently in a closed demonstrator phase.',
status: { status: {
label: 'System status', label: 'System status',
rows: [ rows: [
{ key: 'Phase', value: 'Closed demonstrator', live: true }, { key: 'Phase', value: 'Closed demonstrator' },
{ key: 'Next', value: 'Alpha release' }, { key: 'Next', value: 'Alpha release' },
{ key: 'Deployment', value: 'Self-hosted' }, { key: 'Focus', value: 'Software security' },
], ],
}, },
notify: { notify: {
@@ -270,6 +253,37 @@ const en = {
consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.', consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
privacy: 'Privacy policy', privacy: 'Privacy policy',
submit: 'Register', 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: { track: {
title: 'Release path.', title: 'Release path.',
@@ -280,19 +294,29 @@ const en = {
{ state: 'Later', name: 'General availability', body: 'Date to follow.' }, { state: 'Later', name: 'General availability', body: 'Date to follow.' },
], ],
}, },
spec: { verticals: {
title: 'What is being built.', title: 'Verticals.',
body: 'CipherVerifier takes a repository, runs seventeen static analysers and two bill-of-materials generators over it, reads each finding in its context and ranks what is real by security impact.', body: 'One pipeline underneath. Each vertical brings the knowledge of one domain to it.',
rows: [ platform: 'CipherVerifier platform',
{ key: 'Static analysers', value: '17, each in its own container' }, tiles: [
{ key: 'Languages', value: '14, plus Docker, Kubernetes, Helm, Terraform and CloudFormation' }, {
{ key: 'Bill of materials', value: '2 generators, merged into one CycloneDX document' }, state: 'Integrated',
{ key: 'Vocabulary', value: '970 CWE entries' }, name: 'Telecommunication security',
{ key: 'Pipeline', value: '5 stages, each switchable per repository' }, title: 'Built from Open RAN security research.',
{ key: 'Review', value: 'Two-stage verdict on every finding' }, 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.',
{ key: 'Deployment', value: 'Self-hosted' }, 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',
},
], ],
figure: 'Figure 1. One run, five stages.',
}, },
}, },
}, },
@@ -303,10 +327,11 @@ const en = {
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: { research: {
title: ['Published', 'research.'], title: ['Published', 'research.'],
@@ -335,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.' },
@@ -355,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',
@@ -366,54 +397,39 @@ 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.'],
}, },
manifesto: { manifesto: {
lines: ['Sicherheit eingebaut,', 'nicht nachträglich angefügt.'], 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 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: '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.' },
],
partners: 'Vertrauen und Zusammenarbeit',
partnerRoles: { incubator: 'Inkubator', aloe: 'Allianz für offene Unternehmensnetze', passau: 'Forschungspartner' },
}, },
quote: { quote: {
label: 'Aussagen der Gründer', label: 'Aussagen der Gründer',
@@ -569,13 +585,13 @@ const de: typeof en = {
soon: { soon: {
eyebrow: 'Produkt', eyebrow: 'Produkt',
title: ['CipherVerifier.', 'Demnächst.'], title: ['CipherVerifier.', 'Demnächst.'],
lead: 'Eine selbst gehostete Verifikations-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.', lead: 'Eine selbst gehostete Security-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
status: { status: {
label: 'Systemstatus', label: 'Systemstatus',
rows: [ rows: [
{ key: 'Phase', value: 'Geschlossener Demonstrator', live: true }, { key: 'Phase', value: 'Geschlossener Demonstrator' },
{ key: 'Als Nächstes', value: 'Alpha-Release' }, { key: 'Als Nächstes', value: 'Alpha-Release' },
{ key: 'Betrieb', value: 'Selbst gehostet' }, { key: 'Fokus', value: 'Software sicherheit' },
], ],
}, },
notify: { notify: {
@@ -586,6 +602,37 @@ const de: typeof en = {
consent: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.', 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', privacy: 'Datenschutzerklärung',
submit: 'Registrieren', 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: { track: {
title: 'Weg zum Release.', title: 'Weg zum Release.',
@@ -596,19 +643,29 @@ const de: typeof en = {
{ state: 'Später', name: 'Allgemeine Verfügbarkeit', body: 'Termin folgt.' }, { state: 'Später', name: 'Allgemeine Verfügbarkeit', body: 'Termin folgt.' },
], ],
}, },
spec: { verticals: {
title: 'Was entsteht.', title: 'Vertikalen.',
body: 'CipherVerifier nimmt ein Repository, lässt siebzehn statische Analysewerkzeuge und zwei Stücklisten-Generatoren darüber laufen, liest jeden Fund in seinem Kontext und ordnet, was echt ist, nach Sicherheitsrelevanz.', body: 'Darunter eine Pipeline. Jede Vertikale bringt das Wissen einer Domäne mit.',
rows: [ platform: 'Plattform CipherVerifier',
{ key: 'Statische Analyse', value: '17 Werkzeuge, jedes im eigenen Container' }, tiles: [
{ key: 'Sprachen', value: '14, dazu Docker, Kubernetes, Helm, Terraform und CloudFormation' }, {
{ key: 'Stückliste', value: '2 Generatoren, zu einem CycloneDX-Dokument zusammengeführt' }, state: 'Integriert',
{ key: 'Vokabular', value: '970 CWE-Einträge' }, name: 'Telekommunikations\u00ADsicherheit',
{ key: 'Pipeline', value: '5 Stufen, jede pro Repository abschaltbar' }, title: 'Aus der Open-RAN-Sicherheitsforschung.',
{ key: 'Urteil', value: 'Zweistufig, für jeden Fund' }, 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.',
{ key: 'Betrieb', value: 'Selbst gehostet' }, 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',
},
], ],
figure: 'Abbildung 1. Ein Lauf, fünf Stufen.',
}, },
}, },
}, },
@@ -620,9 +677,10 @@ const de: typeof en = {
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: { research: {
title: ['Veröffentlichte', 'Forschung.'], title: ['Veröffentlichte', 'Forschung.'],
@@ -651,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',
},
}, },
}; };
+3 -1
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 {
@@ -34,7 +35,7 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
{noindex && <meta name="robots" content="noindex" />} {noindex && <meta name="robots" content="noindex" />}
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<!-- Tints the browser chrome (the iOS status bar) to match the surface the page opens on: <!-- Tints the browser chrome (the iOS status bar) to match the surface the page opens on:
--night for a dark hero, --paper otherwise. --> --night for a dark hero, --paper otherwise. The header script updates it on scroll. -->
<meta name="theme-color" content={tone === 'night' ? '#0E100F' : '#E9E5DF'} /> <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} />
@@ -51,6 +52,7 @@ 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>
+2 -4
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';
@@ -19,9 +18,8 @@ const ui = t(lang);
<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} />
+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>
+21 -8
View File
@@ -4,16 +4,29 @@ import { t } from '../../i18n/ui';
const ui = t('de'); const ui = t('de');
--- ---
<Legal lang="de" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}> <Legal lang="de" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}>
<h2>Verantwortlich</h2> <h2>Diensteanbieter</h2>
<p>Dr. Felix Klement<br />CipherCell<br />Innstr. 43<br />94032 Passau<br />Deutschland</p>
<h2>Kontakt</h2>
<p>Telefon: +49 851 509 3218<br />E-Mail: felix.klement@ciphercell.de<br />Website: https://ciphercell.de</p>
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Deutschland</p> <p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Deutschland</p>
<p>Telefon: <a href="tel:+498515093218">+49 851 509 3218</a><br />E-Mail: <a href="mailto:felix.klement@ciphercell.de">felix.klement@ciphercell.de</a></p>
<h2>Inhalt der Website</h2> <h2>Haftung für Inhalte</h2>
<p>CipherCell (der Webseitenbetreiber) prüft und aktualisiert die Informationen auf seinen Webseiten regelmäßig. Trotz aller Sorgfalt übernimmt der Webseitenbetreiber keine Haftung oder Garantie für die Aktualität, Richtigkeit, Vollständigkeit oder Qualität der online verfügbaren Informationen. Alle Angebote sind freibleibend und unverbindlich. Der Webseitenbetreiber behält es sich ausdrücklich vor, einzelne Seiten oder das gesamte Onlineangebot ohne vorhergehende Ankündigung zu verändern, zu ergänzen, zu löschen oder das Angebot auf bestimmte Zeit oder auch endgültig einzustellen.</p> <p>Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
<h2>Verweise und Links</h2> <p>Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.</p>
<p>CipherCell haftet nicht für den Inhalt von fremden Webseiten, auf die im Onlineangebot von CipherCell z. B. mittels Hyperlinks verwiesen wird. Dies gilt auch für alle Einträge Dritter in vom Webseitenbetreiber eingerichteten Gästebüchern, Diskussionsforen, Pinnwänden und Mailinglisten.</p>
<h2>Urheber- und Kennzeichenrecht</h2> <h2>Haftung für Links</h2>
<p>Inhalt und Struktur der Webseite von CipherCell sind urheberrechtlich geschützt. Die Vervielfältigung von Grafiken, Tondokumenten, Bildmaterialien, Film- und Videoausschnitten und Texten in anderen elektronischen oder gedruckten Medien und Publikationen ist ohne die ausdrückliche Erlaubnis von CipherCell nicht gestattet.</p> <p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar.</p>
<p>Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Links umgehend entfernen.</p>
<h2>Urheberrecht</h2>
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.</p>
<p>Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.</p>
</Legal> </Legal>
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="confirmed" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="error" />
+106 -18
View File
@@ -1,31 +1,119 @@
--- ---
import Legal from '../_Legal.astro'; import Legal from '../_Legal.astro';
import OptOut from '../../components/OptOut.astro';
import { t } from '../../i18n/ui'; import { t } from '../../i18n/ui';
const ui = t('de'); const ui = t('de');
--- ---
<Legal lang="de" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell nimmt die Belange des Datenschutzes ernst und erhebt, verarbeitet und nutzt Ihre personenbezogenen Daten ausschließlich in Übereinstimmung mit den nachfolgend beschriebenen Grundsätzen und unter Beachtung der anwendbaren Datenschutzgesetze."> <Legal lang="de" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell nimmt die Belange des Datenschutzes ernst und erhebt, verarbeitet und nutzt Ihre personenbezogenen Daten ausschließlich in Übereinstimmung mit den nachfolgend beschriebenen Grundsätzen und unter Beachtung der anwendbaren Datenschutzgesetze.">
<h2>Verantwortliche Stelle</h2> <h2>Datenschutzerklärung</h2>
<p>Die verantwortliche Stelle für den Betrieb der CipherCell-Website (im Folgenden „Website“) sowie die Erhebung, Verarbeitung und Nutzung Ihrer personenbezogenen Daten im Sinne des anwendbaren Datenschutzrechts ist Dr. Felix Klement, Innstr. 43, 94032 Passau, Deutschland.</p>
<h2>Webanalyse mit Matomo (ohne Cookies)</h2> <p>Diese Datenschutzerklärung erläutert, wie CipherCell personenbezogene Daten verarbeitet, wenn Sie unsere Website besuchen, mit uns Kontakt aufnehmen, sich für die Benachrichtigung zur Alpha-Version von CipherVerifier anmelden oder eingebettete LinkedIn-Beiträge laden.</p>
<p>Wir verwenden auf unserer Website die Open-Source-Webanalysesoftware Matomo, um das Nutzungsverhalten auf datenschutzfreundliche Weise auszuwerten und unsere Website technisch und inhaltlich zu verbessern. Dabei haben wir Matomo so konfiguriert, dass keine Cookies gesetzt werden und Ihre IP-Adresse anonymisiert gespeichert wird. Es werden keine personenbezogenen Daten verarbeitet, und es erfolgt keine Weitergabe an Dritte. Die Verarbeitung erfolgt auf Grundlage unseres berechtigten Interesses gemäß Art. 6 Abs. 1 lit. f DSGVO an der statistischen Analyse des Nutzerverhaltens zur Optimierung unseres Webangebots. Wenn Ihr Browser die „Do Not Track“-Funktion unterstützt und aktiviert ist, wird Ihr Besuch von Matomo automatisch ignoriert. Sie können der anonymisierten Analyse Ihres Besuchs dennoch jederzeit widersprechen. Nutzen Sie dazu bitte den folgenden Opt-out.</p>
<p><a href="https://cipher.uber.space/matomo/index.php?module=CoreAdminHome&action=optOut&language=de" rel="noopener">Matomo Opt-out</a></p>
<h2>Datensicherheit</h2> <h2>1. Verantwortlicher und Kontakt</h2>
<p>Ihre Nutzerdaten sind bei der Übermittlung auf unsere Server durch den Einsatz von TLS in Verbindung mit einer 256-Bit-Verschlüsselung geschützt. Sie können die Sicherheit der Verbindung anhand der Angaben in der Adresszeile Ihres Browsers prüfen. Beginnt die Adresszeile mit „https“, liegt eine gesicherte Verbindung vor.</p> <p>Verantwortlicher für die Datenverarbeitung auf dieser Website ist:</p>
<p>Dr. Felix Klement<br />CipherCell<br />Innstr. 43<br />94032 Passau<br />Deutschland</p>
<h2>Kontaktaufnahme</h2> <p>E-Mail: office@ciphercell.de</p>
<p>Wenn Sie uns per E-Mail oder auf anderem Wege kontaktieren, speichern wir Ihre übermittelten personenbezogenen Daten (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht), um Ihre Anfrage zu bearbeiten. Diese Daten werden ausschließlich für die Kommunikation mit Ihnen verwendet und nicht an Dritte weitergegeben. Rechtsgrundlage für die Verarbeitung der Daten ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertragsverhältnisses) oder Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bearbeitung der Anfrage).</p>
<h2>Benachrichtigung zur CipherVerifier-Alpha</h2> <h2>2. Bereitstellung und Sicherheit der Website</h2>
<p>Wenn Sie sich für die Benachrichtigung zur Alpha von CipherVerifier eintragen, speichern wir Ihre E-Mail-Adresse und den Zeitpunkt Ihrer Einwilligung, um Ihnen mitzuteilen, wann die erste Alpha veröffentlicht ist. Wir nutzen sie für nichts anderes und geben sie nicht an Dritte weiter. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> widerrufen; wir löschen Ihre Adresse dann. Nach dem Versand der Benachrichtigung löschen wir sie ebenfalls.</p> <p>Wenn Sie unsere Website besuchen, werden technische Informationen verarbeitet, um die von Ihnen angeforderten Seiten bereitzustellen und die Verfügbarkeit und Sicherheit der Website zu gewährleisten.</p>
<h2>Eingebettete LinkedIn-Beiträge</h2> <p>Hierzu gehören Ihre IP-Adresse, Datum und Uhrzeit der Anfrage, die angeforderte URL, der Antwortstatus, Browserinformationen und die zuvor besuchte Seite, von der aus Sie auf unsere Website gelangt sind.</p>
<p>Einige Seiten unter Einblicke können einen Beitrag von LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Irland) anzeigen. Beim Aufruf der Seite wird der Beitrag nicht geladen. Erst wenn Sie auf die Schaltfläche zum Laden klicken, verbindet sich Ihr Browser mit den Servern von LinkedIn; diese erhalten Ihre IP-Adresse und die aufgerufene Seite und können Cookies setzen. Sind Sie bei LinkedIn angemeldet, kann LinkedIn den Besuch Ihrem Konto zuordnen. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie mit diesem Klick erteilen. Zur Verarbeitung durch LinkedIn siehe dessen Datenschutzrichtlinie unter <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
<h2>Recht auf Auskunft, Berichtigung, Sperrung oder Löschung</h2> <p>Das Hosting erfolgt durch Dr. Felix Klement, Anschrift wie oben angegeben, Deutschland.</p>
<p>Nach dem anwendbaren Datenschutzrecht haben Sie ein Recht auf unentgeltliche Auskunft über Ihre gespeicherten Daten sowie ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Wenden Sie sich bei weitergehenden Fragen zum Datenschutz und zur Verarbeitung Ihrer personenbezogenen Daten, bei Auskünften oder zur Ausübung Ihres Rechts auf Berichtigung, Sperrung oder Löschung oder für den Fall des Widerrufs einer Einwilligung bitte per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. Selbstverständlich können Sie auch die im Impressum angegebenen Kontaktdaten oder das Kontaktformular auf der Website nutzen. CipherCell verarbeitet und nutzt die von Ihnen mitgeteilten Informationen ausschließlich für die Bearbeitung Ihres Anliegens.</p>
<h2>Anpassungen</h2> <p>Wir verarbeiten diese Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO. Unsere berechtigten Interessen bestehen darin, eine funktionsfähige Website zu betreiben, Störungen zu erkennen sowie Missbrauch und Angriffe zu verhindern.</p>
<p>CipherCell behält sich vor, diese Datenschutzbestimmungen von Zeit zu Zeit anzupassen. Wir werden Sie von jeder Änderung oder Ergänzung vorab rechtzeitig unterrichten und, soweit erforderlich, Ihre Einwilligung in die geänderte Verarbeitung oder Nutzung Ihrer personenbezogenen Daten einholen.</p>
</Legal> <p>Serverprotokolle werden nach sieben Tagen gelöscht. Soweit einzelne Protokolleinträge zur Untersuchung eines Sicherheitsvorfalls oder zur Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen erforderlich sind, bewahren wir diese so lange auf, wie dies für den jeweiligen Zweck notwendig ist.</p>
<p>Die Verarbeitung der technischen Informationen, die zur Bereitstellung einer angeforderten Seite erforderlich sind, ist für den Betrieb der Website notwendig.</p>
<h2>3. Kontaktanfragen</h2>
<p>Wenn Sie uns per E-Mail oder telefonisch kontaktieren, verarbeiten wir die von Ihnen übermittelten Kontaktdaten, Nachrichteninhalte und Anhänge, um Ihre Anfrage zu beantworten.</p>
<p>Soweit die Verarbeitung zur Durchführung vorvertraglicher Maßnahmen auf Ihre Anfrage hin oder zur Erfüllung eines Vertrags mit Ihnen erforderlich ist, erfolgt sie auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Bei sonstigen Anfragen ist Art. 6 Abs. 1 lit. f DSGVO die Rechtsgrundlage. Unser berechtigtes Interesse besteht darin, an uns gerichtete Anfragen zu beantworten.</p>
<p>Unser E-Mail-Dienst wird von uberspace, Jonas Pasche, Deutschland, bereitgestellt. Weitere Informationen finden Sie unter <a href="https://uberspace.de" rel="noopener">https://uberspace.de</a>.</p>
<p>Allgemeine Anfragen löschen wir nach 30 Tagen. Soweit für die Korrespondenz gesetzliche Aufbewahrungspflichten bestehen, bewahren wir die erforderlichen Unterlagen für die gesetzlich vorgeschriebene Dauer auf. Rechtsgrundlage hierfür ist Art. 6 Abs. 1 lit. c DSGVO.</p>
<p>Die Bereitstellung Ihrer Angaben ist freiwillig. Um Ihre Anfrage beantworten zu können, benötigen wir jedoch ausreichende Informationen und eine Möglichkeit, Sie zu kontaktieren.</p>
<h2>4. Websiteanalyse mit Matomo</h2>
<p>Wir verwenden Matomo, um die Nutzung unserer Website besser zu verstehen und ihre Inhalte sowie ihre technische Leistungsfähigkeit zu verbessern.</p>
<p>Unsere Matomo-Installation ist so konfiguriert, dass keine Analyse-Cookies eingesetzt und IP-Adressen gekürzt werden.</p>
<p>Matomo wird von Dr. Felix Klement, Anschrift wie oben angegeben, Deutschland, gehostet. Ausschließlich CipherCell erhält die Analysedaten.</p>
<p>Rechtsgrundlage ist Ihre Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO. Soweit im Rahmen der Analyse Informationen auf Ihrem Endgerät gespeichert werden oder auf dort gespeicherte Informationen zugegriffen wird, holen wir hierfür zusätzlich Ihre Einwilligung gemäß § 25 Abs. 1 TDDDG ein.</p>
<p>Wenn Ihr Browser die Funktion „Do Not Track“ unterstützt und Sie diese aktiviert haben, wird Ihr Besuch von Matomo automatisch nicht erfasst. Sie können der anonymisierten Auswertung Ihres Besuchs auch jederzeit widersprechen. Nutzen Sie hierfür bitte die nachfolgende Opt-out-Möglichkeit.</p>
<OptOut lang="de" />
<h2>5. Benachrichtigung zur Alpha-Version von CipherVerifier</h2>
<p>Sie können sich anmelden, um einmalig per E-Mail über die Veröffentlichung der ersten Alpha-Version von CipherVerifier informiert zu werden. Wir verwenden diese Anmeldung nicht für einen allgemeinen Newsletter oder für Werbung zu anderen Themen.</p>
<p>Wir verarbeiten Ihre E-Mail-Adresse, um Ihnen eine Bestätigungs-E-Mail zu senden. Erst nachdem Sie Ihre Anmeldung über den darin enthaltenen Link bestätigt haben, nehmen wir Ihre Adresse in die aktive Benachrichtigungsliste auf.</p>
<p>Der Bestätigungslink ist sieben Tage gültig. Wenn Sie Ihre Anmeldung nicht innerhalb dieser Frist bestätigen, werden die bei der Anmeldung erfassten Daten </p>unverzüglich gelöscht.
<p>Bei bestätigten Anmeldungen speichern wir Ihre E-Mail-Adresse und die weiteren Daten, die Sie bei der Anmeldung angegeben haben.</p>
<p>Rechtsgrundlage für die Anmeldung und den Versand der Benachrichtigung ist Ihre Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO.</p>
<p>Unser Dienstleister für den E-Mail-Versand ist uberspace, Jonas Pasche, Deutschland. Weitere Informationen finden Sie unter <a href="https://uberspace.de" rel="noopener">https://uberspace.de</a>.</p>
<p>Sie können Ihre Einwilligung jederzeit widerrufen, indem Sie den Abmeldelink in einer unserer E-Mails zur Benachrichtigung über die Alpha-Version von CipherVerifier anklicken oder eine E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> senden. Wir entfernen Ihre Adresse anschließend aus der aktiven Benachrichtigungsliste.</p>
<p>Wir entfernen Ihre Adresse außerdem aus dieser Liste, sobald wir die Benachrichtigung versandt haben oder die Veröffentlichung abgesagt wird – je nachdem, welches </p>Ereignis zuerst eintritt.
<p>Die Anmeldung ist freiwillig. Ohne Ihre E-Mail-Adresse und die Bestätigung Ihrer Anmeldung können wir Ihnen keine Benachrichtigung senden.</p>
<h2>6. Eingebettete LinkedIn-Beiträge</h2>
<p>Auf einigen Seiten bieten wir Beiträge an, die von LinkedIn Ireland Unlimited Company, Irland, gehostet werden. Diese Beiträge werden zunächst als Platzhalter angezeigt. LinkedIn-Inhalte werden erst geladen, wenn Sie <i>„Beitrag von LinkedIn laden“</i> auswählen.</p>
<p>Vor dem Laden eines Beitrags weisen wir Sie darauf hin, dass dadurch eine Verbindung zwischen Ihrem Browser und LinkedIn hergestellt wird und LinkedIn möglicherweise Cookies setzt. Der Platzhalter enthält außerdem einen Link zu dieser Datenschutzerklärung.</p>
<p>Mit der Auswahl von „Beitrag von LinkedIn laden“ willigen Sie in das Laden des ausgewählten Beitrags und die damit verbundene Übermittlung personenbezogener Daten an LinkedIn ein. Ihr Browser übermittelt LinkedIn technische Informationen, darunter Ihre IP-Adresse und Browserinformationen. Dabei kann LinkedIn auch erfahren, welche Seite Sie gerade besuchen. LinkedIn kann Cookies setzen oder auslesen oder ähnliche Technologien einsetzen. Wenn Sie bei LinkedIn angemeldet sind, kann LinkedIn diese Interaktion Ihrem Benutzerkonto zuordnen.</p>
<p>Unser Zweck ist es, den von Ihnen ausgewählten LinkedIn-Beitrag anzuzeigen. Rechtsgrundlage für die Verarbeitung, für die wir verantwortlich sind, ist Ihre Einwilligung gemäß Art. 6 Abs. 1 lit. a DSGVO. Die Einwilligung umfasst auch die damit verbundene Speicherung von Informationen auf Ihrem Endgerät oder den Zugriff auf dort gespeicherte Informationen gemäß § 25 Abs. 1 TDDDG.</p>
<p>Sie können unsere Website weiterhin nutzen, ohne den Beitrag zu laden.</p>
<p>Sie können Ihre Einwilligung jederzeit widerrufen, indem Sie die Seite neu laden. Durch den Widerruf werden das erneute Laden und weitere Datenübermittlungen über die betreffende Einbettung verhindert. Die Rechtmäßigkeit der bis zum Widerruf erfolgten Verarbeitung bleibt unberührt. Bereits an LinkedIn übermittelte Informationen lassen sich dadurch nicht zurückholen.</p>
<p>LinkedIn kann Daten außerhalb des Europäischen Wirtschaftsraums verarbeiten, unter anderem in den Vereinigten Staaten.</p>
<p>Einzelheiten zu den Verarbeitungszwecken, der Speicherdauer, internationalen Datenübermittlungen und Ihren Datenschutzrechten bei LinkedIn finden Sie in der <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">Datenschutzrichtlinie von LinkedIn</a>.</p>
<h2>7. Dienstleister und internationale Datenübermittlungen</h2>
<p>Die oben genannten Dienstleister erhalten personenbezogene Daten nur, soweit dies für die Erbringung ihrer jeweiligen Leistungen erforderlich ist. Soweit sie Daten in unserem Auftrag verarbeiten, erfolgt dies auf Grundlage von Auftragsverarbeitungsverträgen und nach unseren Weisungen.</p>
<h2>8. Ihre Rechte</h2>
<p>Unter den in der DSGVO geregelten Voraussetzungen haben Sie das Recht auf Auskunft über Ihre personenbezogenen Daten, auf Berichtigung unrichtiger Daten, auf Löschung sowie auf Einschränkung der Verarbeitung.</p>
<p>Wenn die Verarbeitung automatisiert erfolgt und auf einer Einwilligung oder einem Vertrag beruht, kann Ihnen außerdem das Recht zustehen, die von Ihnen bereitgestellten Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten und diese Daten einem anderen Verantwortlichen zu übermitteln.</p>
<p><b>Widerspruchsrecht:</b> Soweit wir Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO verarbeiten, können Sie aus Gründen, die sich aus Ihrer besonderen Situation ergeben, Widerspruch gegen die Verarbeitung einlegen. Wir verarbeiten die Daten dann nicht mehr, es sei denn, wir können zwingende schutzwürdige Gründe für die Verarbeitung nachweisen, die Ihre Interessen, Rechte und Freiheiten überwiegen, oder die Verarbeitung dient der Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen. Der Verarbeitung Ihrer Daten zum Zweck der Direktwerbung können Sie jederzeit widersprechen. Wir werden Ihre Daten dann nicht mehr für diesen Zweck verarbeiten.</p>
<p><b>Widerruf einer Einwilligung:</b> Sie können eine erteilte Einwilligung jederzeit widerrufen. Die Rechtmäßigkeit der aufgrund Ihrer Einwilligung bis zum Widerruf erfolgten Verarbeitung bleibt hiervon unberührt.</p>
<p>Um Ihre Rechte auszuüben, kontaktieren Sie uns unter office@ciphercell.de oder schreiben Sie an die oben angegebene Anschrift. Die Bearbeitung Ihrer Anträge ist grundsätzlich unentgeltlich. Wir beantworten Ihre Anträge in der Regel innerhalb eines Monats. Sollte eine gesetzlich zulässige Fristverlängerung erforderlich sein, informieren wir Sie innerhalb dieses Monats darüber und erläutern die Gründe.</p>
<p>Sie haben außerdem das Recht, Beschwerde bei einer Datenschutzaufsichtsbehörde einzulegen, insbesondere in dem EU-Mitgliedstaat Ihres gewöhnlichen Aufenthaltsorts, Ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes.</p>
<p>Für Verantwortliche im nichtöffentlichen Bereich in Bayern ist das Bayerische Landesamt für Datenschutzaufsicht (BayLDA) zuständig. Informationen und Beschwerdemöglichkeiten finden Sie auf der<a href="https://www.lda.bayern.de/de/beschwerde.html" rel="noopener">Website des BayLDA</a>.</p>
<h2>9. Automatisierte Entscheidungen</h2>
<p>Wir treffen derzeit keine ausschließlich auf einer automatisierten Verarbeitung – einschließlich Profiling – beruhenden Entscheidungen über Sie, die Ihnen gegenüber rechtliche Wirkung entfalten oder Sie in ähnlicher Weise erheblich beeinträchtigen, im Sinne von Art. 22 DSGVO.</p>
<h2>10. Datensicherheit</h2>
<p>Wir verwenden TLS-Verschlüsselung für Verbindungen zu unserer Website und setzen technische und organisatorische Maßnahmen ein, die den mit der Verarbeitung personenbezogener Daten verbundenen Risiken angemessen sind.</p>
<h2>11. Änderungen dieser Datenschutzerklärung</h2>
<p>Wir aktualisieren diese Datenschutzerklärung, wenn sich unsere Datenverarbeitung oder die geltenden Anforderungen ändern. Das Datum der letzten Aktualisierung finden Sie am Anfang dieser Erklärung.</p>
<p>Wenn wir beabsichtigen, personenbezogene Daten für einen neuen Zweck zu verwenden, stellen wir Ihnen vorab die erforderlichen Informationen zur Verfügung und holen, soweit erforderlich, eine neue Einwilligung ein.</p>
</Legal>
+13 -8
View File
@@ -4,16 +4,21 @@ import { t } from '../i18n/ui';
const ui = t('en'); const ui = t('en');
--- ---
<Legal lang="en" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}> <Legal lang="en" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}>
<h2>Responsible</h2> <h2>Provider</h2>
<p>Dr. Felix Klement<br />CipherCell<br />Innstr. 43<br />94032 Passau<br />Germany</p>
<h2>Contact</h2>
<p>Phone: +49 851 509 3218<br />E-Mail: felix.klement@ciphercell.de<br />Website: https://ciphercell.de</p>
<h2>Person Responsible for Editorial Content (pursuant to § 18 (2) MStV)</h2>
<p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Germany</p> <p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Germany</p>
<p>Telephone: <a href="tel:+498515093218">+49 851 509 3218</a><br />Email: <a href="mailto:felix.klement@ciphercell.de">felix.klement@ciphercell.de</a></p>
<h2>Website content</h2> <h2>Liability for Content</h2>
<p>CipherCell (the website operator) regularly reviews and updates the information on its websites. Despite all due care, the website operator accepts no liability or guarantee for the timeliness, accuracy, completeness, or quality of the information available online. All offers are subject to change and non-binding. The website operator expressly reserves the right to change, supplement, delete or discontinue the offer at any time without prior notice. The website operator is not responsible for the content of external links.</p> <p>As a service provider, we are responsible for our own content on these pages under general statutory law in accordance with § 7 (1) DDG. However, pursuant to §§ 8 to 10 DDG, we are not obligated to monitor transmitted or stored third-party information or to investigate circumstances pointing to illegal activity. Obligations to remove or block the use of information under general statutory law remain unaffected.</p>
<h2>References and links</h2> <h2>Liability for Links</h2>
<p>CipherCell is not liable for the content of external websites referred to in CipherCell's online offering, e.g. via hyperlinks. This also applies to all entries by third parties in guest books, discussion forums, message boards, and mailing lists set up by the website operator.</p> <p>Our website may contain links to external third-party websites over whose content we have no control. Therefore, we cannot assume any liability for such external content. The respective provider or operator of the linked pages is always responsible for their content. Should any legal infringements become known to us, we will remove such links immediately.</p>
<h2>Copyright and trademark law</h2> <h2>Copyright</h2>
<p>The content and structure of the CipherCell website are protected by copyright. The reproduction of graphics, audio documents, image material, film and video excerpts, and texts in other electronic or printed media and publications is not permitted without the express permission of CipherCell. The use of trademarks and logos is also subject to the provisions of the applicable trademark law.</p> <p>The content and works published on these pages are governed by German copyright law. Any reproduction, processing, distribution, or any form of commercialization beyond the scope of the copyright law requires the prior written consent of the respective author or creator.</p>
</Legal> </Legal>
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="confirmed" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="error" />
+106 -18
View File
@@ -1,31 +1,119 @@
--- ---
import Legal from './_Legal.astro'; import Legal from './_Legal.astro';
import OptOut from '../components/OptOut.astro';
import { t } from '../i18n/ui'; import { t } from '../i18n/ui';
const ui = t('en'); const ui = t('en');
--- ---
<Legal lang="en" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell takes data protection seriously and collects, processes and uses your personal data exclusively in accordance with the principles described below and in compliance with applicable data protection law."> <Legal lang="en" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell takes data protection seriously and collects, processes and uses your personal data exclusively in accordance with the principles described below and in compliance with applicable data protection law.">
<h2>Responsible body</h2> <h2>Privacy Notice</h2>
<p>The responsible body for the operation of the CipherCell website (hereinafter referred to as "website") and the collection, processing, and use of your personal data within the meaning of applicable data protection law is Dr. Felix Klement, Innstr. 43, 94032 Passau, Germany.</p> <p>This notice explains how CipherCell processes personal data when you visit our website, contact us, subscribe to the CipherVerifier alpha notification, or choose to display embedded LinkedIn posts.</p>
<h2>Web analytics with Matomo (without cookies)</h2> <h2>1. Controller and contact</h2>
<p>We use the open source web analytics software Matomo on our website to evaluate user behavior in a privacy-friendly manner and to improve our website in terms of technology and content. We have configured Matomo so that no cookies are set and your IP address is stored anonymously. No personal data is processed and no data is passed on to third parties. Processing is based on our legitimate interest pursuant to Art. 6 (1) lit. f GDPR in the statistical analysis of user behavior for the purpose of optimizing our website. If your browser supports and has activated the "Do Not Track" function, your visit will be automatically ignored by Matomo. You can still object to the anonymized analysis of your visit at any time. To do so, please use the opt-out below.</p> <p>The controller responsible for this website is:</p>
<p><a href="https://cipher.uber.space/matomo/index.php?module=CoreAdminHome&action=optOut&language=en" rel="noopener">Matomo opt-out</a></p>
<h2>Data security</h2>
<p>Your user data is protected during transmission to our servers through the use of TLS in conjunction with 256-bit encryption. You can check the security of the connection by looking at the information in your browser's address bar. If the beginning of the address line reads "https", the connection is secure.</p>
<h2>Contact</h2> <p>Dr. Felix Klement<br />CipherCell<br />Innstr. 43<br />94032 Passau<br />Germany</p>
<p>If you contact us by email or other means, we will store the personal data you provide (e.g. name, email address, content of the message) in order to process your request. This data will be used exclusively for communication with you and will not be passed on to third parties. The legal basis for processing the data is Art. 6 para. 1 lit. b GDPR (initiation of a contractual relationship) or Art. 6 para. 1 lit. f GDPR (legitimate interest in processing the request).</p>
<h2>CipherVerifier alpha notification</h2> <p>Email: office@ciphercell.de</p>
<p>If you register for the CipherVerifier alpha notification, we store your email address and the time of your consent in order to tell you when the first alpha is released. We use it for nothing else and do not pass it on to third parties. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR. You can withdraw your consent at any time by email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>; we then delete your address. We also delete it once the notification has been sent.</p>
<h2>Embedded LinkedIn posts</h2> <h2>2. Website delivery and security</h2>
<p>Some pages in Insights can show a post from LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Ireland). The post is not loaded when you open the page. Only when you click the button to load it does your browser connect to LinkedIn's servers, which receive your IP address and the page you are on and may set cookies; if you are logged in to LinkedIn, it can link the visit to your account. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR, given by that click. For LinkedIn's processing, see its privacy policy at <a href="https://www.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p> <p>When you visit our website, technical information is processed to deliver the pages you request and maintain the website’s availability and security.</p>
<h2>Right to information, correction, blocking, or deletion</h2> <p>This includes the IP address, request date and time, requested URL, response status, browser information, and referring page.</p>
<p>Under applicable data protection law, you have the right to obtain information about your stored data free of charge and, if necessary, the right to correct, block, or delete this data. If you have any further questions about data protection and the processing of your personal data, if you require information, or if you wish to exercise your right to correction, blocking, or deletion, or in the event of revocation of consent, please send an email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. You can also use the contact details provided in the legal notice or the contact form on the website. CipherCell processes and uses the information you provide exclusively for the purpose of processing your request.</p>
<h2>Adjustments</h2> <p>Hosting is provided by Dr. Felix Klement, adddress as above, Germany.</p>
<p>CipherCell reserves the right to amend these data protection provisions from time to time. We will inform you in advance of any changes or additions and, where necessary, obtain your consent to the amended processing or use of your personal data.</p>
</Legal> <p>We process these data under Article 6(1)(f) GDPR. Our legitimate interests are operating a functional website, identifying faults, and preventing misuse and attacks.</p>
<p>Server logs are deleted after 7 days. Where specific records are needed to investigate a security incident or establish, exercise, or defend legal claims, we retain those records for as long as they are needed for the described purpose.</p>
<p>The technical information needed to deliver a requested page must be processed for the website to function.</p>
<h2>3. Contact enquiries</h2>
<p>If you contact us by email or telephone, we process the contact details, message content, and attachments you provide to respond to your enquiry.</p>
<p>Where processing is necessary to take steps at your request before entering into a contract, or to perform a contract with you, the legal basis is Article 6(1)(b) GDPR. For other enquiries, the legal basis is Article 6(1)(f) GDPR; our legitimate interest is responding to communications addressed to us.</p>
<p>Our email service is provided by uberspace, Jonas Pasche, Germany. More information: <a href="https://uberspace.de" rel="noopener">https://uberspace.de</a>.</p>
<p>We delete ordinary enquiries after 30 days. Where correspondence must be retained under applicable legal obligations, we retain the necessary records for as long as we are obligated, based on Article 6(1)(c) GDPR.</p>
<p>Providing information is voluntary. However, we need sufficient information and a way to contact you to answer your enquiry.</p>
<h2>4. Website analytics with Matomo</h2>
<p>We use Matomo to understand how visitors use our website and to improve its content and technical performance.</p>
<p>Our installation is configured without analytics cookies and masks IP addresses.</p>
<p>Matomo is hosted by Dr. Felix Klement, address as above, Germany. Only CipherCell recieves analytics data.</p>
<p>The legal basis is Article 6(1)(a) GDPR. Where analytics involves storing information on or accessing information from your device, we also obtain consent under §25(1) TDDDG.</p>
<p>If your browser supports and has activated the "Do Not Track" function, your visit will be automatically ignored by Matomo. You can still object to the anonymized analysis of your visit at any time. To do so, please use the opt-out below.</p>
<OptOut lang="en" />
<h2>5. CipherVerifier alpha notification</h2>
<p>You can register to receive one email announcing the first CipherVerifier alpha release. We do not use this registration for a general newsletter or unrelated advertising.</p>
<p>We process your email address to send a confirmation email. Your address is added to the active notification list only after you confirm using the link in that email.</p>
<p>The confirmation link expires after seven days. If not confirmed in time, the data from your registration is deleted immediately. </p>
<p>For confirmed registrations, we keep your email address and the additional data that you submitted during the registration.</p>
<p>The legal basis for registration and the notification is your consent under Article 6(1)(a) GDPR.</p>
<p>Our email delivery provider is uberspace, Jonas Pasche, Germany. More information: <a href="https://uberspace.de" rel="noopener">https://uberspace.de</a></p>
<p>You may withdraw your consent at any time through clicking the unsubscribe link in any of our emails related to the CipherVerifier alpha notification or by emailing <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. We then remove your address from the active notification list.</p>
<p>We also remove your address from that list after sending the notification or if the release is cancelled whichever occurs first.</p>
<p>Registration is optional. Without your email address and confirmation, we cannot send you the notification.</p>
<h2>6. Embedded LinkedIn posts</h2>
<p>Some pages offer posts hosted by LinkedIn Ireland Unlimited Company, Ireland. These posts are initially displayed as placeholders. LinkedIn content is loaded only when you select <i>“Load post from LinkedIn.”</i></p>
<p>Before loading a post, we inform you that doing so connects your browser to LinkedIn and that LinkedIn may set cookies. The placeholder also links to this privacy notice.</p>
<p>By selecting “Load post from LinkedIn,” you consent to loading the selected post and the associated transmission of personal data to LinkedIn. Your browser sends LinkedIn technical information, including your IP address and browser information, and may disclose the page you are visiting. LinkedIn may set or read cookies or use similar technologies. If you are logged in to LinkedIn, it may associate this interaction with your account.</p>
<p>Our purpose is to display the LinkedIn post you choose to view. The legal basis for the processing for which we are responsible is your consent under Article 6(1)(a) GDPR. Consent also covers associated storage of information on, or access to information from, your device under §25(1) TDDDG.</p>
<p>You can leave the post unloaded and continue using our website.</p>
<p>You can withdraw your consent at any time by reloading the page. Withdrawal prevents further loading and transmission through the affected embed. It does not affect the lawfulness of processing before withdrawal or undo information already transmitted to LinkedIn.</p>
<p>LinkedIn may process data outside the European Economic Area, including in the United States.</p>
<p>Details about LinkedIn’s purposes, retention practices, international transfers, and privacy rights are available in <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">LinkedIn’s Privacy Policy</a>.</p>
<h2>7. Service providers and international transfers</h2>
<p>The providers identified above receive personal data only as needed for their respective services. Where they process data on our behalf, we engage them under data processing agreements and instructions.</p>
<h2>8. Your rights</h2>
<p>Subject to the conditions set out in the GDPR, you may request access to your personal data, correction of inaccurate data, erasure, and restriction of processing.</p>
<p>Where processing is automated and based on consent or a contract, you may also have the right to receive data you provided in a structured, commonly used, machine-readable format and to transmit those data to another controller.</p>
<p><b>Right to object:</b> Where we process data under Article 6(1)(f) GDPR, you may object on grounds relating to your particular situation. We will stop the processing unless we demonstrate compelling legitimate grounds that override your interests, rights, and freedoms, or need the data for legal claims. You may object to processing for direct marketing at any time; we will then stop that processing.</p>
<p><b>Withdrawal of consent:</b> You may withdraw consent at any time without affecting the lawfulness of processing carried out before withdrawal.</p>
<p>To exercise your rights, contact office@ciphercell.de or write to the address above. Requests are normally free of charge. We generally respond within one month; if a legally permitted extension is necessary, we will explain this within that month.</p>
<p>You may also complain to a supervisory authority, particularly in the EU Member State where you habitually live or work, or where you consider an infringement occurred.</p>
<p>For private-sector controllers in Bavaria, the relevant authority is the Bavarian State Office for Data Protection Supervision (BayLDA). Information and complaint options are available on the <a href="https://www.lda.bayern.de/de/beschwerde.html" rel="noopener">BayLDA website</a>.</p>
<h2>9. Automated decisions</h2>
<p>We do not currently make decisions about you based solely on automated processing, including profiling, that produce legal effects concerning you or similarly significantly affect you within the meaning of Article 22 GDPR.</p>
<h2>10. Data security</h2>
<p>We use TLS encryption for connections to our website and maintain technical and organisational safeguards appropriate to the risks of processing personal data.</p>
<h2>11. Changes to this notice</h2>
<p>We update this notice when our processing practices or applicable requirements change. The revision date appears above.</p>
<p>If we intend to use personal data for a new purpose, we will provide the required information before doing so and obtain new consent where necessary.</p>
</Legal>
+34
View File
@@ -0,0 +1,34 @@
/**
* Proof of work for the site's forms (ALTCHA). From the moment a visitor first focuses a form,
* a signed challenge is fetched from /contact/challenge and solved in the background; the send
* then carries the answer as the field `altcha`. One challenge per send: the server refuses a
* second use, so every send starts a new one.
*/
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
async function solve(): Promise<string | null> {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
}
/** Starts solving on the form's first focus. `take()` waits for the answer and hands it over once. */
export function proofOfWork(form: HTMLFormElement) {
let proof: Promise<string | null> | null = null;
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
return {
async take(): Promise<string | null> {
const answer = await prepare();
proof = null;
return answer;
},
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Sends a form in place and answers in its toast (components/Toast.astro, found by the form's id).
* Every send carries the answer to a proof-of-work challenge (scripts/altcha.ts), worked out in
* the background from the moment the visitor first touches the form. The texts for the answer
* come from the form: data-sent, data-invalid, data-failed.
*/
import { proofOfWork } from './altcha';
type State = 'sending' | 'sent' | 'failed';
export function sendInPlace(form: HTMLFormElement) {
const toast = document.querySelector<HTMLElement>(`[data-toast="${form.id}"]`);
if (!toast) return;
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
const pow = proofOfWork(form);
const show = (state: State, text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
const altcha = await pow.take();
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, body });
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}
+1 -4
View File
@@ -44,10 +44,6 @@
/* The same for a field whose text sits centred on the left (the home page Insights cards). */ /* The same for a field whose text sits centred on the left (the home page Insights cards). */
--field-scrim-centre: radial-gradient(90% 85% at 20% 50%, rgb(14 16 15 / 0.72), rgb(14 16 15 / 0) 100%); --field-scrim-centre: radial-gradient(90% 85% at 20% 50%, rgb(14 16 15 / 0.72), rgb(14 16 15 / 0) 100%);
/* Signal: the one status tone the brief allows. Used only for a live state (the coming-soon
page's current phase), at most twice per page. Never for text, links or decoration. */
--signal: #58C8D8;
/* Selected text: the logo cyan with base ink on it (about 9:1), the same on paper and night. */ /* Selected text: the logo cyan with base ink on it (about 9:1), the same on paper and night. */
--selection: var(--logo-cyan); --selection: var(--logo-cyan);
--selection-ink: var(--ink-base); --selection-ink: var(--ink-base);
@@ -118,6 +114,7 @@
/* Shape */ /* Shape */
--hairline: 1px; --hairline: 1px;
--bar: 2px; /* a marker rule beside a text block, heavier than a hairline */
--radius: 0; --radius: 0;
--radius-sm: 2px; --radius-sm: 2px;