262 lines
18 KiB
Markdown
262 lines
18 KiB
Markdown
# CipherCell homepage relaunch
|
|
|
|
Static site for www.ciphercell.de, built with Astro. Design foundation: `CLAUDE.md`
|
|
(European deep-tech minimalism: warm paper, one neo-grotesque, hairlines, no accent colour).
|
|
|
|
## Run
|
|
|
|
```sh
|
|
npm install
|
|
npm run dev # http://localhost:4321
|
|
npm run build # static output in dist/
|
|
npm run preview # serve dist/
|
|
npm run check # Astro + TypeScript check
|
|
```
|
|
|
|
## Docker / Dokploy
|
|
|
|
The `Dockerfile` builds the site with Node and serves `dist/` from nginx on port 80
|
|
(config in `docker/nginx.conf`: clean URLs without trailing slash, 404 page, gzip, long caching for
|
|
`/_astro/`, health check at `/healthz`).
|
|
|
|
```sh
|
|
docker build -t ciphercell .
|
|
docker run --rm -p 8080:80 ciphercell # http://localhost:8080
|
|
```
|
|
|
|
In Dokploy: create an Application from this repository, set Build Type to **Dockerfile**
|
|
(path `Dockerfile`), and under Domains add the domain with container port **80** and HTTPS on.
|
|
TLS is handled by Dokploy's Traefik. The contact form needs the SMTP variables under
|
|
Environment; see *Contact form* below.
|
|
|
|
## Where things live
|
|
|
|
| Path | Purpose |
|
|
|---|---|
|
|
| `src/styles/tokens.css` | Every colour, type step, spacing, radius and duration. Components use only these. |
|
|
| `src/styles/base.css` | Font faces, reset, focus ring, primitives (`.container`, `.grid`, `.section`, `.prose`, `.link`, `.button`). |
|
|
| `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/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/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/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/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. |
|
|
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
|
|
| `server/contact.mjs`, `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`. |
|
|
| `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. |
|
|
| `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). |
|
|
| `public/video/` | The home hero's background loop, MP4 and WebM. Shipped as-is; Astro does not process video. |
|
|
|
|
## Routes
|
|
|
|
`/`, `/cipherverifier`, `/team`, `/insights`, `/insights/<slug>`, `/imprint`, `/privacy`, `/404` and the same under `/de`.
|
|
`hreflang` alternates and a sitemap are generated. The language switch sits in the footer.
|
|
|
|
## Things the host has to provide
|
|
|
|
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
|
|
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
|
|
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
|
|
`noindex` and left out of the sitemap. The handler caps size and field lengths, refuses
|
|
cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour overall.
|
|
|
|
Against spam, every message carries the answer to a proof-of-work challenge (ALTCHA,
|
|
`altcha-lib`). The form fetches a signed challenge from `GET /contact/challenge` when the
|
|
visitor first focuses it and solves it in the background (about half a second on a laptop, one
|
|
to two on a phone). The handler refuses a post without a valid, unexpired, unused solution, or
|
|
one sent less than 3 seconds after the challenge was issued. Nothing is stored in the browser
|
|
and no third party is involved, so there is nothing to consent to. The form therefore needs
|
|
JavaScript; without it a `<noscript>` line points to the email address.
|
|
|
|
In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `GET /notify/verify` to the handler on `127.0.0.1:3000`; both are started
|
|
by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down,
|
|
visitors are sent to the error page. Without SMTP settings the handler does not start and the
|
|
rest of the site is served as usual.
|
|
|
|
| Variable | Default | Purpose |
|
|
|---|---|---|
|
|
| `SMTP_HOST` | (required) | Mail server. |
|
|
| `SMTP_PORT` | `587` | `587` uses STARTTLS (required), `465` TLS from the start. |
|
|
| `SMTP_USER`, `SMTP_PASS` | | Login. Leave unset for a server without authentication. |
|
|
| `CONTACT_FROM` | `SMTP_USER` | Sender address; must be one the SMTP account may send as. |
|
|
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
|
|
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
|
|
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. |
|
|
| `NOTIFY_SECRET` | random per start | Signs alpha confirmation links (`openssl rand -hex 32`). Set it, or links sent before a restart stop working. |
|
|
| `SITE_URL` | `https://www.ciphercell.de` | Base of the confirmation links. |
|
|
| `ALTCHA_HMAC_SECRET`, `ALTCHA_HMAC_KEY_SECRET` | random per start | Signing secrets for the challenge (`openssl rand -hex 32`). Set them so a restart does not void challenges already handed out. |
|
|
|
|
In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see
|
|
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies the same paths to it.
|
|
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
|
|
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
|
|
`list=cipherverifier-alpha`, the honeypot `website` and the proof-of-work answer `altcha` to
|
|
`POST /notify`, handled by `server/contact.mjs` like the contact form. It is double opt-in
|
|
without a database: the handler emails the address a link to `GET /notify/verify` carrying a
|
|
signed token (address, language, time of consent; valid 7 days) and keeps nothing. Opening the
|
|
link confirms: the handler sends the confirmed registration, with the consent wording and both
|
|
times, to `CONTACT_TO`, and redirects to `/notify/confirmed`. A mail scanner that opens every
|
|
link in a message will confirm it too. Old `/notify/confirm` links are redirected by nginx. That mailbox is the list. The privacy page promises deletion
|
|
after the alpha mail is sent or on withdrawal. The result pages `/notify/confirmed` and
|
|
`/notify/error` are `noindex` and left out of the sitemap. Set the flag to false to bring back
|
|
the full product page.
|
|
- **Matomo.** The privacy page links to the existing opt-out on `cipher.uber.space`. The tracking
|
|
snippet itself is not included; add it in `src/layouts/Base.astro` if analytics should continue.
|
|
|
|
## Insights filter
|
|
|
|
The Insights index can be filtered by type: Essays (posts), LinkedIn (mentions with
|
|
`platform: 'LinkedIn'`) and Other (mentions from any other platform). A filter only appears once it
|
|
has an entry, and the bar only when there are at least two types. The choice is kept in
|
|
`?type=essay|linkedin|other`, so a filtered view can be linked. The types are in `src/data/insights.ts`.
|
|
|
|
## Adding a mention
|
|
|
|
A mention is a short pointer to a post elsewhere (LinkedIn) that CipherCell took part in. It appears
|
|
in the Insights list and on the home page, sorted by date with the essays, and has a short page at
|
|
`/insights/<slug>`: headline, summary, a link out, and the post embedded on request. The embed is
|
|
two-click: nothing loads from LinkedIn until the visitor presses the button (see the privacy page).
|
|
The embed URL is derived from a LinkedIn post link; set `embed` by hand only if that fails, and
|
|
`embedHeight` (pixels, default 720) if the post is cut off or leaves a gap. Text below the front
|
|
matter is shown as our own notes. A slug used by both an essay and a mention fails the build. Create `src/content/mentions/<slug>.md` with front matter only:
|
|
|
|
```yaml
|
|
title: 'Component security testing at the xG-ALOE Plugfest 2026.' # our headline, sentence case
|
|
summary: 'One or two sentences in our words on what the post says.'
|
|
url: 'https://www.linkedin.com/posts/...' # paste the share link; query and fragment are stripped
|
|
source: 'xG-ALOE' # who posted it
|
|
platform: 'LinkedIn' # optional, defaults to LinkedIn
|
|
date: 2026-09-17 # the post's publication date
|
|
lang: en # language of the post itself
|
|
```
|
|
|
|
Summarise only what the post says.
|
|
|
|
## Adding a post
|
|
|
|
Create `src/content/insights/<slug>.md` with the front matter used in `xg-aloe-user-forum.md`.
|
|
Put the cover image in `src/assets/images/` and reference it with a relative path.
|
|
The three newest posts appear on the home page automatically.
|
|
|
|
## The card drawings
|
|
|
|
The objects on the stage cards ("What we do.") and the product cards do not animate on arrival;
|
|
they are simply drawn. Hovering a card sets `--object-grid`, which the drawing answers by letting
|
|
its grid recede to 26% while the lines that carry the idea stay: the cube's own edges, the tile
|
|
lifted out of the plane, the wave's profiles, the inner cube inside its shell, the column to fix
|
|
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.
|
|
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 hero video
|
|
|
|
The home hero plays `public/video/hero-plant.mp4` / `.webm` behind the headline, over the
|
|
`plant-at-dusk.jpg` still. The still is the video's own first frame, so it carries the hero until
|
|
the video is ready and stays put for anyone who never gets one.
|
|
|
|
The video is only fetched when it will be seen: not under `prefers-reduced-motion`, not on
|
|
`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
|
|
|
|
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
|
|
(Seedream 5 Pro) on 18 September 2026 in a desaturated documentary style, because no real
|
|
photography existed:
|
|
|
|
| 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 |
|
|
| `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-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
|
|
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
|
|
(https://www.magnific.com/app/creation/aFsD5crfSh) and then rebuilt as procedural SVG so the
|
|
stroke matches the rest of the site.
|
|
|
|
## Product page
|
|
|
|
`/cipherverifier` describes the pipeline in `product_repo/monocipher` (mounted for reference, ignored
|
|
by git). Everything on the page was read from that repository on 18 September 2026:
|
|
|
|
- The five stages and their order come from `orchestrator/pipeline_plan.py`.
|
|
- The seventeen analysers are rayfusion's built-in plugins plus converters; the fourteen languages
|
|
are the `Technology` enum in `rayfusion/plugins/base.py`. The security profile is described in
|
|
`docs/rayfusion-rule-policy.md`.
|
|
- The two-stage classification, escalation rules, budgets and replay-validated cache are from
|
|
`docs/polygraph-classification-pipeline.md`.
|
|
- 970 is the number of entries in `sarifcombiner/cwe_information.json`.
|
|
- SBOM merging (Syft and cdxgen, package-URL deduplication, CycloneDX 1.6) is `spectograph/backend`,
|
|
Dependency-Track upload and the ephemeral container are `deptrace/commands`.
|
|
- Git providers, archive upload, CI webhook, scoped settings, live pipeline, PDF report and artefacts
|
|
are the `cipherverifier` Django app; the installer is `cvm`.
|
|
|
|
When the product changes, update `ui.verifier` (copy and diagram labels) and, if a stage or tool is
|
|
added, the layouts in `diagrams.ts`. All five schematics are on the page; `scroll-shots.mjs` renders
|
|
them for review.
|
|
|
|
## Interaction and motion
|
|
|
|
Three islands of script: the mobile navigation toggle in `Header.astro`, the sectors tabs in
|
|
`Sectors.astro` (`src/scripts/tabs.ts`: ARIA tablist, arrow keys, Home and End), and the reveal
|
|
observer used by the product page's drawings.
|
|
|
|
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
|
|
everything that does not belong to it. The drawings themselves do not animate.
|
|
|
|
"How CipherVerifier works" follows the pattern of arcos.eu/technology. On wide screens the
|
|
title and a bordered panel hold still while the page scrolls; the step list under the title
|
|
advances, one step open at a time, and the panel crossfades to that step's drawing. The section is
|
|
as tall as one pitch per step plus one panel height, so the panel stays pinned until the last step
|
|
has been read. Which step is open is decided by invisible triggers tiling that height: whichever
|
|
crosses the middle of the viewport wins, and the triggers start half a viewport down so the first
|
|
step turns over exactly when the panel pins. A step's title is a button that scrolls to it.
|
|
|
|
The panel is tied to the text, not to the pinned frame: it is positioned against
|
|
`.how__text-inner`, so it starts at the title and its width is stated as 200% of the text column
|
|
(4 of 12 against 8 of 12). That block is held at one height, measured once by script as the
|
|
collapsed list plus the tallest step's text, and centred in the pinned frame. So the title, the
|
|
list and the panel all sit in the middle of the dark area and none of them move as the steps
|
|
change, while each step's text still takes only the room it needs. Without script the first step stays open, the panel takes the text's own
|
|
height, and the section reads as a plain sequence. Phones get that plain sequence in any case:
|
|
every step open, the upright drawing under its text. This goes
|
|
beyond the home page's single load moment on purpose, at the client's request; the home page is
|
|
unchanged. Without JavaScript every sector panel
|
|
and its image is shown in full; the `html.js` class set in `Base.astro` switches to one at a time.
|