Compare commits

..
14 Commits
Author SHA1 Message Date
Felix Klement 5af2d78328 docker 2026-10-04 19:49:09 +02:00
Felix Klement 8385db6ebf highlight 2026-10-04 19:46:45 +02:00
Felix Klement 018f27156c asdf 2026-10-04 19:40:32 +02:00
Felix Klement 933de1c5a8 asdf 2026-10-04 19:40:22 +02:00
Felix Klement 3865c45642 mixc 2026-10-04 19:12:42 +02:00
Felix Klement 00edb9dfc2 ncei hero 2026-10-04 13:06:04 +02:00
Felix Klement 2b509386eb comming soon 2026-10-04 12:55:12 +02:00
Felix Klement c72d45740e fixes 2026-10-04 11:49:07 +02:00
Felix Klement c3f4651e71 maix 2026-10-03 19:35:22 +02:00
Felix Klement c8740fb1d3 Fix hero 2026-10-03 12:23:33 +02:00
Felix Klement 755d91d0f4 change 2026-10-03 12:02:39 +02:00
Felix Klement 2458df3803 df 2026-10-02 21:38:36 +02:00
Felix Klement 39bb0f480c progress 2026-10-02 21:33:40 +02:00
Felix Klement 49850fe485 Current 2026-10-02 20:47:03 +02:00
60 changed files with 5669 additions and 248 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
.git
.gitignore
.DS_Store
**/.DS_Store
node_modules
dist
.astro
product_repo
.ds-sync
ds-bundle
.design-sync/.cache
.design-sync/learnings
.design-sync/node_modules
docs
Dockerfile
.dockerignore
*.log
.env
.env.*
+1
View File
@@ -9,3 +9,4 @@ ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
# Build the static site.
FROM node:22-bookworm-slim AS build
WORKDIR /app
ENV ASTRO_TELEMETRY_DISABLED=1 \
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# Serve dist/ with nginx.
FROM nginx:1.27-alpine AS runtime
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/healthz || exit 1
+52
View File
@@ -13,6 +13,21 @@ 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.
## Where things live
| Path | Purpose |
@@ -26,6 +41,7 @@ npm run check # Astro + TypeScript check
| `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. |
| `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. |
@@ -43,9 +59,45 @@ npm run check # Astro + TypeScript check
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` and a honeypot
field `website` to `POST /contact`, as the current site does. The handler is server-side and
not part of this static build.
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is
server-side and not part of this static build. It should store the address with the time of
consent, confirm it by double opt-in, and redirect back with a thank-you. The privacy page
promises deletion after the alpha mail is sent. 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`.
+53
View File
@@ -0,0 +1,53 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# TLS terminates at Dokploy's Traefik; keep redirects relative so they stay on https.
absolute_redirect off;
server_tokens off;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/xml application/javascript application/json image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# trailingSlash: 'never' — /team is served from /team/index.html; /team/ redirects to /team.
location ~ ^(.+)/$ {
return 301 $1$is_args$args;
}
location / {
try_files $uri $uri.html $uri/index.html =404;
}
# Astro's hashed build assets never change.
location ^~ /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
location ~* \.(?:woff2|mp4|webm|avif|webp|jpg|jpeg|png|svg)$ {
add_header Cache-Control "public, max-age=2592000" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
+6 -6
View File
@@ -2725,9 +2725,9 @@
}
},
"node_modules/devalue": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz",
"integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==",
"version": "5.9.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
"license": "MIT"
},
"node_modules/devlop": {
@@ -3153,9 +3153,9 @@
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz",
"integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==",
"license": "BSD-2-Clause"
},
"node_modules/iron-webcrypto": {
+4 -4
View File
@@ -5,10 +5,10 @@
"private": true,
"types": ".design-sync/ds-types/index.d.ts",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check"
"dev": "ASTRO_TELEMETRY_DISABLED=1 astro dev",
"build": "ASTRO_TELEMETRY_DISABLED=1 astro build",
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
"check": "ASTRO_TELEMETRY_DISABLED=1 astro check"
},
"dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0",
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
"""Give every paper in src/data/research.json its own Chladni figure and colour field.
Each paper gets `figure` (parameters for src/scripts/chladni.ts) and `field` (three colour tokens
and their positions), seeded from the paper id so a re-run gives the same result and a new paper
gets its own. Generation follows the rules learned while hand-tuning the first figures:
- plate windows stay clear of both axes and of any coordinate where n·t and m·t are both whole
(the field is zero along a straight line there), plates are never mirrored (the seams read as
straight lines), and the
weights avoid a = ±b (which zeroes the diagonals u = ±v);
- hover keeps both plate terms in play (a single sin·sin term is a straight grid);
- source figures use three to six sources (two sources draw a straight bisector), spread over
enough of a wavelength that they interfere (radius × frequency >= 0.6 for five or six sources,
0.35 for three or four); packed tighter they collapse into plain concentric rings;
- one colour patch sits in the middle of the card, so no field is mostly black;
- line density, the share of grid cells a nodal line passes through, stays within the range of
the hand-picked figures, so no card is near-empty or a scribble.
A paper that already has a figure keeps it unless --regenerate is passed, so hand edits survive.
Usage: python3 scripts/research-figures.py [--regenerate] [--report]
"""
import hashlib, json, math, random, sys
PATH = 'src/data/research.json'
ASPECT = 3 # the card canvas is 3:1
Q = 61 # grid for the density check (the site renders at 121)
COLOURS = [
'logo-deep', 'logo-mid', 'logo-teal', 'logo-cyan',
'field-indigo', 'field-petrol', 'field-sea', 'field-steel', 'field-dusk',
'field-cobalt', 'field-ocean', 'field-glacier', 'field-aqua', 'field-jade', 'field-slate', 'field-plum',
]
# Light tones carry the glow; at most one per field, so every card keeps a dark side.
LIGHT = {'logo-cyan', 'field-glacier', 'field-aqua', 'logo-teal'}
def field_fn(f, shape=None):
s = {**f, **(shape or {})}
if f['mode'] == 'plate':
a, b, n, m = s['a'], s['b'], s['n'], s['m']
rot = s.get('rotation', 0)
c, si = math.cos(rot), math.sin(rot)
ox, oy = f['origin']
def z(x, y):
dx, dy = x - ox, y - oy
u, v = ox + dx * c - dy * si, oy + dx * si + dy * c
return a * math.sin(math.pi * n * u) * math.sin(math.pi * m * v) + b * math.sin(math.pi * m * u) * math.sin(math.pi * n * v)
return z
pts = []
for i in range(f['sources']):
t = i / f['sources'] * 2 * math.pi - math.pi / 2 + s.get('spin', 0)
pts.append((f['origin'][0] + s['radius'] * math.cos(t), f['origin'][1] + s['radius'] * math.sin(t)))
k = 2 * math.pi * s['frequency']
inv = 1 / s['elasticity']
return lambda x, y: sum(math.sin(k * (s['phase'] - inv * math.hypot(x - px, y - py))) for px, py in pts)
def density(f, shape=None):
z = field_fn(f, shape)
h = f['length']; w = h * ASPECT
x0, y0 = f['origin'][0] - w / 2, f['origin'][1] - h / 2
g = [[z(x0 + c * w / (Q - 1), y0 + r * h / (Q - 1)) > 0 for c in range(Q)] for r in range(Q)]
cells = sum(1 for r in range(Q - 1) for c in range(Q - 1) if len({g[r][c], g[r][c + 1], g[r + 1][c], g[r + 1][c + 1]}) > 1)
return cells / (Q - 1) ** 2
def straight_lines(n, m, origin, hx, hy):
"""True if the window crosses a coordinate where n·t and m·t are both whole: both terms of the
plate vanish there for every value of the other coordinate, which draws a straight line."""
for centre, half in ((origin[0], hx), (origin[1], hy)):
steps = 400
for i in range(steps + 1):
t = centre - half + 2 * half * i / steps
if abs(n * t - round(n * t)) < 0.03 and abs(m * t - round(m * t)) < 0.03:
return True
return False
def rnd(rng, lo, hi, step=0.01):
return round(rng.uniform(lo, hi) / step) * step
def make_plate(rng, rotated):
while True:
n, m = rnd(rng, 1.1, 4.6), rnd(rng, 1.1, 4.6)
if abs(n - m) < 0.35: continue
length = rnd(rng, 1.0, 2.8)
if not (2.4 <= max(n, m) * length <= 8.5): continue
a = rnd(rng, 0.8, 1.7) * rng.choice([-1, 1])
b = rnd(rng, 0.4, 1.5) * rng.choice([-1, 1])
if abs(abs(a) - abs(b)) < 0.25: continue
rotation = rnd(rng, 0.2, 1.3) if rotated else 0
# Keep the whole (rotated) window off both axes.
half_diag = math.hypot(length * ASPECT / 2, length / 2) if rotated else None
hx = half_diag if rotated else length * ASPECT / 2
hy = half_diag if rotated else length / 2
origin = [-round(hx + rnd(rng, 0.3, 2.5), 2), round(hy + rnd(rng, 0.3, 2.5), 2)]
f = {'mode': 'plate', 'a': a, 'b': b, 'n': n, 'm': m, 'origin': origin, 'length': length}
if rotated: f['rotation'] = rotation
if straight_lines(n, m, origin, hx, hy): continue
# Hover: flip the dominant weight and bring it down to 40-75 % of the other. Never near zero
# (a single sin·sin term draws a grid of straight lines) and never near a = ±b.
ha = None
for _ in range(20):
cand = round(-math.copysign(1, a) * abs(b) * rng.uniform(0.4, 0.75), 2)
if abs(abs(cand) - abs(b)) >= 0.2:
ha = cand; break
if ha is None: continue
f['hover'] = {'a': ha}
return f
def make_sources(rng):
while True:
count = rng.choice([3, 3, 4, 4, 5, 6])
length = rnd(rng, 1.8, 2.8)
f = {
'mode': 'sources', 'sources': count,
'frequency': rnd(rng, 1.0, 2.5), 'phase': rnd(rng, 0, 0.5), 'elasticity': 1,
'radius': rnd(rng, 0.3, min(1.1, length / 2.2)),
'origin': [rnd(rng, -1.3, 1.3), rnd(rng, -0.4, 0.4)], 'length': length,
'spin': rnd(rng, 0, 2 * math.pi / count),
}
if f['radius'] * f['frequency'] < (0.6 if count >= 5 else 0.35): continue
f['hover'] = {'phase': round(f['phase'] + rnd(rng, 0.35, 0.55), 2), 'spin': round(f['spin'] + rnd(rng, 0.35, 0.65), 2)}
return f
def make_field(rng):
while True:
cols = rng.sample(COLOURS, 3)
if sum(c in LIGHT for c in cols) <= 1: break
pos = [[rnd(rng, 30, 70, 1), rnd(rng, 30, 70, 1)]] + [[rnd(rng, 5, 95, 1), rnd(rng, 5, 95, 1)] for _ in range(2)]
return {'colors': cols, 'positions': pos}
def generate(pid, lo, hi):
rng = random.Random(int(hashlib.sha256(pid.encode()).hexdigest(), 16))
for _ in range(200):
kind = rng.choices(['sources', 'plate', 'rotated'], [0.45, 0.35, 0.2])[0]
f = make_sources(rng) if kind == 'sources' else make_plate(rng, kind == 'rotated')
d0, d1 = density(f), density(f, f['hover'])
if lo <= d0 <= hi and lo * 0.8 <= d1 <= hi * 1.2:
return f, make_field(rng), d0
raise SystemExit('no acceptable figure for ' + pid)
if __name__ == '__main__':
data = json.load(open(PATH))
# Density band, calibrated on the 18 hand-picked figures (0.24 to 0.51 on this grid), without
# the two busiest, which read as scribbles at small card sizes.
lo, hi = 0.23, 0.45
regen = '--regenerate' in sys.argv
for p in data['papers']:
if 'figure' in p and not regen: continue
p['figure'], p['field'], d = generate(p['id'], lo, hi)
if '--report' in sys.argv: print(f"{d:.3f} {p['figure']['mode']:8} {p['id'][:50]}")
json.dump(data, open(PATH, 'w'), indent=2, ensure_ascii=False)
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 549 KiB

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+62
View File
@@ -0,0 +1,62 @@
---
/* An abstract colour field with film grain: the picture for an Insights card that has none.
Palette and blob positions follow from `seed` (the entry's slug), so a card keeps its field
wherever it appears and however the list is sorted or filtered. Slot content sits on the
lower edge in paper-coloured type. */
interface Props { seed: string; class?: string }
const { seed, class: className } = Astro.props;
/* A base and three blobs per palette, chosen so neighbouring hues sit well together. */
const palettes = [
['field-plum', 'field-ember', 'field-peach', 'field-rose'],
['field-cobalt', 'field-sky', 'field-lilac', 'field-peach'],
['field-jade', 'field-mint', 'field-sun', 'field-sky'],
['field-violet', 'field-rose', 'field-lilac', 'field-sun'],
['field-ocean', 'logo-cyan', 'field-mint', 'field-ember'],
['field-ember', 'field-sun', 'field-rose', 'field-peach'],
];
/* FNV-1a, then a small LCG for the positions: stable across builds and machines. */
let h = 2166136261;
for (const ch of seed) h = Math.imul(h ^ ch.codePointAt(0)!, 16777619) >>> 0;
const next = () => (h = (Math.imul(h, 1664525) + 1013904223) >>> 0) / 2 ** 32;
const [base, ...blobs] = palettes[h % palettes.length];
const at = (lo: number, hi: number) => Math.round(lo + next() * (hi - lo));
/* Blobs stay mostly in the upper two thirds, so the type at the bottom keeps a calmer ground. */
const style = [`--base: var(--${base})`]
.concat(blobs.map((c, k) => `--c${k + 1}: var(--${c}); --x${k + 1}: ${at(0, 100)}%; --y${k + 1}: ${at(0, 70)}%`))
.join('; ');
---
<div class:list={['field', className]} style={style}>
<slot />
</div>
<style>
.field {
position: relative;
isolation: isolate;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-5);
background-color: var(--base);
background-image:
var(--field-scrim),
radial-gradient(70% 90% at var(--x1) var(--y1), var(--c1), transparent 70%),
radial-gradient(60% 80% at var(--x2) var(--y2), var(--c2), transparent 68%),
radial-gradient(85% 110% at var(--x3) var(--y3), var(--c3), transparent 72%);
color: var(--paper);
}
/* Film grain, coarser and stronger than on the research cards: the texture is the point. */
.field::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
opacity: 0.75;
}
</style>
+197 -53
View File
@@ -9,7 +9,6 @@ const home = localePath(lang, '/');
const items = [
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
{ href: localePath(lang, '/cipherverifier'), label: ui.nav.product },
{ href: `${home}#sectors`, label: ui.nav.sectors },
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
{ href: localePath(lang, '/team'), label: ui.nav.team },
];
@@ -17,89 +16,196 @@ const contactHref = `${home}#contact`;
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
const isCurrent = (href: string) => href.split('#')[0] === path && !href.includes('#');
---
<header class="header" data-header>
<div class="container header__inner">
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}><Wordmark /></a>
<header class="header" data-header data-tone="paper">
<div class="container header__shell">
<div class="header__bar hud-frame">
<a class="header__home" href={home} aria-label={ui.nav.homeLabel}>
<Wordmark inline class="header__logo header__logo--paper" />
<Wordmark inline light class="header__logo header__logo--night" />
</a>
<nav class="header__nav" aria-label="Primary" id="primary-nav">
<ul class="header__list" role="list">
{items.map((item) => (
<li><a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>{item.label}</a></li>
))}
<li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li>
</ul>
</nav>
<nav class="header__nav hud-frame" aria-label="Primary" id="primary-nav">
<ul class="header__list" role="list">
{items.map((item) => {
const current = isCurrent(item.href);
return (
<li><a class:list={[{ 'hud-corners': current }]} href={item.href} aria-current={current ? 'page' : undefined}>{item.label}</a></li>
);
})}
<li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li>
</ul>
</nav>
<a class="button button--small header__contact" href={contactHref}>{ui.nav.contact}</a>
<a class="header__contact" href={contactHref}>{ui.nav.contact}</a>
<button class="header__toggle" type="button" aria-expanded="false" aria-controls="primary-nav" data-nav-toggle>
<span data-label-open>{ui.nav.menu}</span>
<span data-label-close hidden>{ui.nav.close}</span>
</button>
<button class="header__toggle" type="button" aria-expanded="false" aria-controls="primary-nav" data-nav-toggle>
<span data-label-open>{ui.nav.menu}</span>
<span data-label-close hidden>{ui.nav.close}</span>
</button>
</div>
</div>
</header>
<style>
/* The header is a transparent sticky strip; the bar floats inside it with room on every side
for the corner crosses. Only the bar takes pointer events, so the margin never blocks clicks. */
.header {
position: sticky;
top: 0;
z-index: 20;
background: var(--paper);
border-bottom: var(--hairline) solid var(--line);
pointer-events: none;
}
.header__inner {
.header__shell {
padding-block: var(--space-3);
transition:
max-width var(--dur-slow) var(--ease),
padding var(--dur-slow) var(--ease);
}
/* Once the page scrolls, the bar docks: full width, flush to the top, and as tall as the header
so nothing below moves. Its outer crosses would sit off-screen (and the side ones would make
the page scroll sideways), so they go, with the top and side borders; the bottom line stays. */
.header[data-scrolled] .header__shell {
max-width: 100%;
padding: 0;
}
.header[data-scrolled] .header__bar {
min-height: var(--header-height);
border-width: 0 0 var(--line);
}
.header[data-scrolled] .header__bar::after { content: none; }
.header__bar {
pointer-events: auto;
isolation: isolate;
display: flex;
align-items: stretch;
min-height: calc(var(--header-height) - 2 * var(--space-3));
color: var(--ink-base);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease),
min-height var(--dur-slow) var(--ease);
}
/* The glass sits on a pseudo-element rather than the bar itself: a backdrop-filter on the bar
would make it a backdrop root, and the mobile panel inside it could then blur nothing. */
.header__bar::before,
.header__nav[data-open]::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: var(--glass-solid);
transition: background-color var(--dur) var(--ease);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.header__bar::before,
.header__nav[data-open]::before {
background: var(--glass);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
}
}
/* Over a night section the bar takes the night tone. */
.header[data-tone='night'] { --hud-line: var(--hud-line-on-night); }
.header[data-tone='night'] .header__bar { color: var(--ink-on-night); }
.header[data-tone='night'] .header__bar::before,
.header[data-tone='night'] .header__nav[data-open]::before { background: var(--glass-solid-on-night); }
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
.header[data-tone='night'] .header__bar::before,
.header[data-tone='night'] .header__nav[data-open]::before { background: var(--glass-on-night); }
}
.header__home {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
min-height: var(--header-height);
}
.header__home { text-decoration: none; }
.header__nav { margin-left: auto; }
.header__list {
display: flex;
gap: var(--space-6);
}
.header__list a {
padding-inline: var(--space-4);
text-decoration: none;
font-weight: var(--weight-regular);
padding-block: var(--space-2);
border-bottom: var(--hairline) solid transparent;
transition: border-color var(--dur-fast) var(--ease);
}
.header__home :global(.header__logo--night),
.header[data-tone='night'] .header__home :global(.header__logo--paper) { display: none; }
.header[data-tone='night'] .header__home :global(.header__logo--night) { display: block; }
/* Cells: every item is a full-height cell, divided from its neighbour by one frame line. */
.header__nav { margin-left: auto; display: flex; }
/* On desktop the nav is just a run of cells inside the bar: no frame of its own. */
.header__nav.hud-frame { border: 0; }
.header__nav.hud-frame::after { content: none; }
.header__list { display: flex; }
.header__list li { display: flex; border-left: var(--line) solid var(--frame-color); }
.header__list a,
.header__contact,
.header__toggle {
display: flex;
align-items: center;
padding-inline: var(--space-4);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease);
}
.header__list a:hover,
.header__list a[aria-current='page'] { border-bottom-color: currentColor; }
.header__contact { margin-left: var(--space-6); }
.header__contact-mobile { display: none; }
.header__toggle:hover { background: color-mix(in srgb, currentColor var(--tint-hover), transparent); }
.header__list a[aria-current='page'] { background: color-mix(in srgb, currentColor var(--tint-active), transparent); }
.header__contact {
border-left: var(--line) solid var(--frame-color);
background: var(--ink-base);
color: var(--paper);
}
.header__contact:hover { background: var(--ink-muted-base); }
.header[data-tone='night'] .header__contact { background: var(--paper); color: var(--night); }
.header[data-tone='night'] .header__contact:hover { background: var(--paper-deep); }
.header__list .header__contact-mobile { display: none; }
.header__toggle {
display: none;
font-weight: var(--weight-medium);
padding: var(--space-2) 0;
border-left: var(--line) solid var(--frame-color);
text-transform: inherit;
letter-spacing: inherit;
}
/* Focus ring drawn inside the cell, so it never collides with the frame lines. */
.header__bar :focus-visible {
outline-offset: calc(-1 * var(--space-1));
border-radius: 0;
}
/* A panel left open across a resize to desktop must not paint a second glass layer. */
@media (min-width: 48rem) {
.header__nav[data-open]::before { content: none; }
}
@media (max-width: 47.99rem) {
.header__contact { display: none; }
.header__contact-mobile { display: block; }
.header__toggle { display: inline-block; }
.header__list .header__contact-mobile { display: flex; }
.header__toggle { display: flex; margin-left: auto; }
.header__nav {
position: absolute;
inset: 100% 0 auto 0;
top: calc(100% + var(--space-3));
left: calc(-1 * var(--line));
right: calc(-1 * var(--line));
display: none;
padding: var(--space-5) var(--gutter) var(--space-7);
background: var(--paper);
border-bottom: var(--hairline) solid var(--line);
}
/* As the mobile panel it is a full .hud-frame: border and corner crosses. */
.header__nav.hud-frame { border: var(--line) solid var(--frame-color); }
.header__nav.hud-frame::after { content: ''; }
.header__nav[data-open] { display: block; }
.header__list { flex-direction: column; gap: 0; }
.header__list li + li { border-top: var(--hairline) solid var(--line); }
/* Docked: the panel hangs straight off the bar's bottom line, edge to edge. */
.header[data-scrolled] .header__nav {
top: calc(100% + var(--line));
border-width: 0 0 var(--line);
}
.header[data-scrolled] .header__nav.hud-frame::after { content: none; }
.header__list { flex-direction: column; }
.header__list li { border-left: 0; }
.header__list li + li { border-top: var(--line) solid var(--frame-color); }
.header__list a {
display: block;
flex: 1;
padding-block: var(--space-4);
font-size: var(--step-h3);
letter-spacing: var(--track-h3);
border-bottom: 0;
font-size: var(--step-small);
}
.header__list a[aria-current='page'] { font-weight: var(--weight-medium); }
}
</style>
@@ -120,4 +226,42 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); });
nav.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) set(false); });
}
/* Scrolled: a one-pixel marker at the very top of the document; once it leaves the viewport
the page has scrolled. An IntersectionObserver, so nothing runs on scroll. */
if (header && 'IntersectionObserver' in window) {
const marker = document.createElement('div');
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;';
document.body.prepend(marker);
new IntersectionObserver(([entry]) => {
header.toggleAttribute('data-scrolled', !entry.isIntersecting);
}).observe(marker);
}
/* Tone: watch a one-pixel line through the middle of the bar and switch to night while a
.night section crosses it. An IntersectionObserver, so nothing runs on scroll. */
const sections = document.querySelectorAll<HTMLElement>('.night');
if (header && sections.length && 'IntersectionObserver' in window) {
const under = new Set<Element>();
let observer: IntersectionObserver | undefined;
const watch = () => {
observer?.disconnect();
under.clear();
const mid = Math.round(header.offsetHeight / 2);
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) under.add(entry.target); else under.delete(entry.target);
}
header.dataset.tone = under.size ? 'night' : 'paper';
}, { rootMargin: `-${mid}px 0px -${window.innerHeight - mid - 1}px 0px` });
sections.forEach((section) => observer!.observe(section));
};
watch();
let resize: number | undefined;
window.addEventListener('resize', () => {
window.clearTimeout(resize);
resize = window.setTimeout(watch, 150);
});
}
</script>
+23 -9
View File
@@ -1,16 +1,30 @@
---
/** The wordmark is set in type. No symbol, no colour. */
interface Props { class?: string }
const { class: className } = Astro.props;
/** The logo, as supplied. Sized by its height so it sits on the header's baseline grid.
`inline` writes it into the page as a data URI, so it paints with the first frame instead of
blinking in on every navigation; the header needs that, the footer does not. */
import logoInline from '../assets/logo.webp?inline';
import logoUrl from '../assets/logo.webp?url';
/* Light lettering for dark surfaces, derived from the supplied logo; the mark is unchanged. */
import logoLightInline from '../assets/logo-light.webp?inline';
import logoLightUrl from '../assets/logo-light.webp?url';
interface Props { class?: string; inline?: boolean; light?: boolean }
const { class: className, inline = false, light = false } = Astro.props;
const src = light ? (inline ? logoLightInline : logoLightUrl) : (inline ? logoInline : logoUrl);
---
<span class:list={['wordmark', className]}>CipherCell</span>
<img
class:list={['wordmark', className]}
src={src}
alt="CipherCell"
width="311"
height="72"
loading={inline ? undefined : 'lazy'}
/>
<style>
.wordmark {
display: inline-block;
font-weight: var(--weight-medium);
letter-spacing: var(--track-h3);
font-size: var(--step-h3);
line-height: 1;
display: block;
height: var(--space-5);
width: auto;
}
</style>
+36 -52
View File
@@ -1,6 +1,6 @@
---
import { Picture } from 'astro:assets';
import plant from '../../assets/images/plant-at-dusk.jpg';
import poster from '../../assets/images/hero-field.png';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
@@ -9,33 +9,26 @@ const ui = t(lang);
---
<section class="hero night" aria-labelledby="hero-title">
<div class="hero__media">
<!-- The still is the video's first frame, so it carries the hero until the video is ready
and stays put for anyone who never gets one: no script, reduced motion, saved data. -->
<!-- Abstract and decorative, so neither layer carries alt text. The poster is a frame of
the noise field, so it carries the hero until the canvas is drawing, and stays for
anyone who never gets one: reduced motion, no WebGL, no script. -->
<div class="hero__layers">
<Picture
src={plant}
alt={ui.hero.imageAlt}
src={poster}
alt=""
quality={70}
formats={['avif']}
fallbackFormat="webp"
widths={[640, 960, 1440, 1920, 2400, 3024]}
widths={[640, 960, 1440, 1920, 2400]}
sizes="100vw"
loading="eager"
fetchpriority="high"
decoding="async"
class="hero__img img-cover img-doc"
class="hero__img img-cover"
pictureAttributes={{ class: 'hero__picture' }}
/>
<video
class="hero__video img-cover img-doc"
muted
loop
playsinline
disablepictureinpicture
preload="none"
aria-hidden="true"
data-hero-video
data-mp4="/video/hero-plant.mp4"
data-webm="/video/hero-plant.webm"
></video>
<canvas class="hero__field" aria-hidden="true" data-hero-field></canvas>
</div>
<div class="hero__overlay"></div>
</div>
@@ -53,8 +46,10 @@ const ui = t(lang);
position: relative;
display: flex;
align-items: flex-end;
/* Fills the screen under the header, and never less than the headline needs. */
min-height: max(28rem, calc(100svh - var(--header-height)));
/* Runs up under the floating header so the glass bar sits on the image, fills the screen,
and is never less than the headline needs. */
margin-top: calc(-1 * var(--header-height));
min-height: max(28rem, 100svh);
overflow: hidden;
}
.hero__media { position: absolute; inset: 0; }
@@ -64,22 +59,24 @@ const ui = t(lang);
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 50%;
object-position: 50% 50%;
}
.hero__layers {
position: absolute;
inset: 0;
transform-origin: 50% 60%;
animation: hero-settle var(--dur-hero) var(--ease) both;
}
.hero__video {
.hero__field {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: 60% 50%;
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
}
/* Set once the video can actually play, so the still never cuts to black. */
.hero__video[data-ready] { opacity: 1; }
/* Set by the first drawn frame, so the poster never cuts to black. */
.hero__field[data-ready] { opacity: 1; }
.hero__overlay {
position: absolute;
inset: 0;
@@ -90,7 +87,10 @@ const ui = t(lang);
width: 100%;
padding-block: var(--space-9);
}
.hero__title { max-width: 12em; }
.hero__title {
max-width: 12em;
font-size: var(--step-hero);
}
.hero__line {
display: block;
opacity: 0;
@@ -108,33 +108,17 @@ const ui = t(lang);
}
@media (prefers-reduced-motion: reduce) {
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
.hero__video { display: none; }
.hero__layers, .hero__line { animation: none; opacity: 1; transform: none; }
.hero__field { display: none; }
}
</style>
<script>
/* The sources are attached here rather than in the markup, so the file is only fetched for
someone who will actually see it move: not under reduced motion, not on saved data, and not
on a phone, where a 21:9 frame is cropped to a sliver and the bytes cost the most. */
const video = document.querySelector<HTMLVideoElement>('[data-hero-video]');
const wants = window.matchMedia('(min-width: 48rem)').matches
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const connection = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
if (video && wants && !connection?.saveData) {
const add = (src: string | null, type: string) => {
if (!src) return;
const source = document.createElement('source');
source.src = src;
source.type = type;
video.append(source);
};
add(video.dataset.webm ?? null, 'video/webm');
add(video.dataset.mp4 ?? null, 'video/mp4');
video.addEventListener('canplay', () => video.setAttribute('data-ready', ''), { once: true });
video.preload = 'auto';
video.load();
/* A blocked autoplay leaves the still in place, which is a fine hero on its own. */
video.play().catch(() => {});
import { startNoiseField } from '../../scripts/noise-field';
const canvas = document.querySelector<HTMLCanvasElement>('[data-hero-field]');
const hero = canvas?.closest<HTMLElement>('.hero');
if (canvas && hero && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
startNoiseField(canvas, hero);
}
</script>
+31 -27
View File
@@ -1,13 +1,13 @@
---
import { getCollection } from 'astro:content';
import { getFeed } from '../../data/insights';
import { Picture } from 'astro:assets';
import Field from '../Field.astro';
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const posts = (await getCollection('insights'))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 2);
const feed = (await getFeed()).slice(0, 2);
const m = ui.insights.mention;
---
<section class="section insights" aria-labelledby="insights-title">
<div class="container">
@@ -17,28 +17,36 @@ const posts = (await getCollection('insights'))
</div>
<ul class="insights__list" role="list">
{posts.map((post) => (
{feed.map((item) => (
<li class="insights__item">
<a class="insights__card" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
<Picture
src={post.data.cover}
alt={post.data.coverAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[400, 640, 960]}
sizes="(min-width: 64rem) 30vw, (min-width: 48rem) 45vw, 100vw"
loading="lazy"
decoding="async"
class="insights__img img-doc"
/>
<p class="small muted">{post.data.category}</p>
<h3 class="h3">{post.data.title}</h3>
</a>
{item.kind === 'post' ? (
<a class="insights__card" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
{item.entry.data.cover ? <Picture
src={item.entry.data.cover}
alt={item.entry.data.coverAlt!}
formats={['avif']}
fallbackFormat="webp"
widths={[400, 640, 960]}
sizes="(min-width: 64rem) 30vw, (min-width: 48rem) 45vw, 100vw"
loading="lazy"
decoding="async"
class="insights__img img-doc"
/> : <Field seed={item.entry.id} class="insights__panel"><span aria-hidden="true">{item.entry.data.category}</span></Field>}
<p class="small muted">{item.entry.data.category}</p>
<h3 class="h3">{item.entry.data.title}</h3>
</a>
) : (
<a class="insights__card" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
<Field seed={item.entry.id} class="insights__panel"><span aria-hidden="true">{item.entry.data.source}</span></Field>
<p class="small muted">{m.category(item.entry.data.platform)}</p>
<h3 class="h3">{item.entry.data.title}</h3>
</a>
)}
</li>
))}
<li class="insights__item">
<a class="insights__card insights__card--event" href={ui.insights.event.href} rel="noopener">
<p class="insights__event-when">{ui.insights.event.when}</p>
<Field seed={ui.insights.event.href} class="insights__panel"><span>{ui.insights.event.when}</span></Field>
<p class="small muted">{ui.insights.event.category}</p>
<h3 class="h3">{ui.insights.event.text}</h3>
</a>
@@ -70,14 +78,10 @@ const posts = (await getCollection('insights'))
.insights__card:hover h3 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; }
.insights__img, :global(.insights__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; margin-bottom: var(--space-2); }
.insights__card h3 { max-width: 18em; }
.insights__event-when {
display: flex;
align-items: flex-end;
/* No picture: a colour field carries the source (or the event date) in large type. */
:global(.insights__panel) {
aspect-ratio: 3 / 2;
padding: var(--space-5);
margin-bottom: var(--space-2);
background: var(--paper-deep);
color: var(--ink-muted);
font-size: var(--step-title);
line-height: var(--leading-title);
letter-spacing: var(--track-h2);
+35
View File
@@ -0,0 +1,35 @@
---
/* One partner logo, two states. At rest: an ink silhouette (the image's alpha is the mask), so a
logo drawn for a dark ground reads as print on paper. On hover of the surrounding
`[data-partner]`: the original colours on night, the ground they were drawn for. */
interface Props { name: string; src: ImageMetadata; url: string; h: string }
const { name, src, url, h } = Astro.props;
---
<span class="logo" style={`--logo: url(${url}); --logo-h: ${h}; --ratio: ${src.width / src.height};`}>
<span class="logo__ink" aria-hidden="true"></span>
<img class="logo__original" src={url} alt={name} width={src.width} height={src.height} loading="lazy" decoding="async" />
</span>
<style>
.logo { display: grid; justify-items: start; width: 100%; }
/* The set height, or smaller where the column is too narrow for it. */
.logo > * { grid-area: 1 / 1; width: min(100%, var(--logo-h) * var(--ratio)); height: auto; aspect-ratio: var(--ratio); }
.logo__ink {
display: block;
background: var(--ink);
mask: var(--logo) left center / contain no-repeat;
transition: opacity var(--dur-fast) var(--ease);
}
/* Kept in the layout for its alt text; shown only on hover. */
.logo__original {
opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
}
@media (hover: hover) {
:global([data-partner]:hover) .logo__ink { opacity: 0; }
:global([data-partner]:hover) .logo__original { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.logo__ink, .logo__original { transition: none; }
}
</style>
+96
View File
@@ -1,8 +1,22 @@
---
import { getImage } from 'astro:assets';
import { t, type Lang } from '../../i18n/ui';
import PartnerLogo from './PartnerLogo.astro';
import incubator from '../../assets/partners/xg-incubator.webp';
import aloe from '../../assets/partners/xg-aloe.webp';
import passau from '../../assets/partners/university-of-passau.webp';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
/* The partner logos as on ciphercell.de (see PartnerLogo.astro for the two states). `h` balances
optical size: the incubator wordmark is long and thin, the other two carry a mark. */
const roles = ui.proof.partnerRoles;
const logos = await Promise.all([
{ name: 'xG-Incubator', role: roles.incubator, src: incubator, h: 'var(--space-5)' },
{ name: 'xG-ALOE', role: roles.aloe, src: aloe, h: 'var(--space-7)' },
{ name: lang === 'de' ? 'Universität Passau' : 'University of Passau', role: roles.passau, src: passau, h: 'var(--space-7)' },
].map(async (l) => ({ ...l, url: (await getImage({ src: l.src, format: 'webp' })).src })));
---
<section class="section proof" aria-labelledby="proof-title">
<div class="container grid">
@@ -15,6 +29,34 @@ const ui = t(lang);
</li>
))}
</ul>
<div class="partners">
<p class="small muted partners__label" id="partners-label">{ui.proof.partners}</p>
<ul class="partners__list" role="list" aria-labelledby="partners-label">
{logos.map((l) => (
<li class="partners__item" data-partner>
<PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} />
</li>
))}
</ul>
</div>
{/* Variant B, for comparison: one column per partner across the full width, with its role.
Keep one of the two and delete the other. */}
<div class="partners-b">
<p class="small muted" id="partners-b-label">{ui.proof.partners}</p>
<ul class="partners-b__list" role="list" aria-labelledby="partners-b-label">
{logos.map((l) => (
<li class="partners-b__item" data-partner>
<PartnerLogo name={l.name} src={l.src} url={l.url} h={l.h} />
<p class="partners-b__text">
<span class="partners-b__name">{l.name}</span>
<span class="small partners-b__role">{l.role}</span>
</p>
</li>
))}
</ul>
</div>
</div>
</section>
@@ -31,9 +73,63 @@ const ui = t(lang);
.proof__item:last-child { border-bottom: var(--hairline) solid var(--line); }
.proof__name { font-weight: var(--weight-medium); }
.proof__body { max-width: 30em; }
.partners {
grid-column: 1 / -1;
margin-top: var(--space-9);
display: grid;
gap: var(--space-5);
}
.partners__list {
display: grid;
border-top: var(--hairline) solid var(--line);
}
.partners__item {
display: flex;
align-items: center;
min-height: var(--space-9);
padding: var(--space-5) var(--space-4);
border-bottom: var(--hairline) solid var(--line);
}
/* Hover answers the pointer with the logo's own ground: night, original colours. */
[data-partner] { transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
@media (hover: hover) {
[data-partner]:hover { background-color: var(--night); color: var(--ink-on-night); }
}
@media (prefers-reduced-motion: reduce) { [data-partner] { transition: none; } }
/* Variant B */
.partners-b {
grid-column: 1 / -1;
margin-top: var(--space-9);
display: grid;
gap: var(--space-5);
}
.partners-b__list { display: grid; border-top: var(--hairline) solid var(--line); }
.partners-b__item {
display: grid;
align-content: space-between;
gap: var(--space-7);
min-height: calc(var(--space-9) * 2.5);
padding: var(--space-6) var(--space-5);
border-bottom: var(--hairline) solid var(--line);
}
.partners-b__text { display: grid; gap: var(--space-1); }
.partners-b__name { font-weight: var(--weight-medium); }
.partners-b__role { color: var(--ink-muted); transition: color var(--dur-fast) var(--ease); }
@media (hover: hover) {
.partners-b__item:hover .partners-b__role { color: var(--ink-muted-on-night); }
}
@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 { grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gap); align-items: start; }
.partners__label { grid-column: 1 / span 6; padding-top: var(--space-5); }
.partners__list { grid-column: 7 / span 6; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: var(--gap); }
.partners-b__list { grid-template-columns: repeat(3, minmax(0, 1fr)); border-bottom: var(--hairline) solid var(--line); }
.partners-b__item { border-bottom: 0; }
.partners-b__item + .partners-b__item { border-left: var(--hairline) solid var(--line); }
}
</style>
+307
View File
@@ -0,0 +1,307 @@
---
import data from '../../data/research.json';
import { t, type Lang } from '../../i18n/ui';
import type { ChladniParams } from '../../scripts/chladni';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const r = ui.team.research;
/* Papers from both founders' Google Scholar profiles, deduplicated (src/data/research.json).
Rendered most cited first, so the page reads right without script; the script below re-sorts,
filters and folds the list. Entries marked display: false stay in the data only. */
const papers = data.papers.filter((p) => p.display && p.href);
const categories = data.categories.filter((c) => papers.some((p) => p.categories.includes(c)));
const countIn = (c: string) => papers.filter((p) => p.categories.includes(c)).length;
const INITIAL = 9;
const labels = { showAll: r.showAll(papers.length), showAllTpl: r.showAll(999).replace('999', '{n}'), showFewer: r.showFewer, count: r.count(111, 999).replace('111', '{shown}').replace('999', '{total}') };
/* Every paper carries its own figure (Chladni parameters for src/scripts/chladni.ts) and colour
field (three tokens and their positions) in research.json, generated per paper by
scripts/research-figures.py. They travel with the paper, so sorting and filtering never move them. */
const figure = (paper: (typeof papers)[number]) => ({ quality: 121, ...paper.figure }) as ChladniParams;
const fieldStyle = (paper: (typeof papers)[number]) =>
paper.field.colors.map((c, k) => `--c${k + 1}: var(--${c}); --ax${k + 1}: ${paper.field.positions[k][0]}%; --ay${k + 1}: ${paper.field.positions[k][1]}%;`).join(' ');
---
<section class="section research" aria-labelledby="research-title">
<div class="container">
<div class="grid research__head">
<h2 id="research-title" class="h2 research__title">
{ui.team.research.title.map((line) => <span class="research__line">{line}</span>)}
</h2>
<p class="research__intro muted">{ui.team.research.intro}</p>
</div>
<div class="research__controls" data-research-controls hidden>
<fieldset class="choice-group">
<legend class="choice-group__legend">{r.sortLabel}</legend>
<div class="choices">
{(['cites', 'newest', 'oldest'] as const).map((key, i) => (
<label class="choice">
<input type="radio" name="research-sort" value={key} checked={i === 0} />
<span>{r.sort[key]}</span>
</label>
))}
</div>
</fieldset>
<fieldset class="choice-group">
<legend class="choice-group__legend">{r.filterLabel}</legend>
<div class="choices">
<label class="choice">
<input type="radio" name="research-category" value="" checked />
<span>{r.all} <span class="choice__n">{papers.length}</span></span>
</label>
{categories.map((c) => (
<label class="choice">
<input type="radio" name="research-category" value={c} />
<span>{r.categories[c] ?? c} <span class="choice__n">{countIn(c)}</span></span>
</label>
))}
</div>
</fieldset>
</div>
<ul class="research__list" role="list" data-research-list data-initial={INITIAL}>
{papers.map((paper, i) => (
<li
class="research__item"
data-cites={paper.cites}
data-date={paper.date ?? String(paper.year)}
data-order={i}
data-categories={paper.categories.join('|')}
>
<a class="paper" href={paper.href} rel="noopener" lang="en">
<div class="paper__field" style={fieldStyle(paper)}>
<canvas class="paper__lines" aria-hidden="true" data-chladni={JSON.stringify(figure(paper))}></canvas>
<span class="paper__year">{paper.year}</span>
</div>
<div class="paper__body">
<h3 class="paper__title">{paper.title}</h3>
<p class="paper__venue small muted">{paper.venue}</p>
<div class="paper__meta small">
<span class="muted" lang={lang}>{paper.categories.map((c) => r.categories[c] ?? c).join(', ')}</span>
</div>
</div>
</a>
</li>
))}
</ul>
<div class="research__foot">
<p class="small muted" aria-live="polite" data-research-count></p>
<button class="research__more" type="button" data-research-more hidden data-labels={JSON.stringify(labels)}>{labels.showAll}</button>
</div>
</div>
</section>
<style>
/* Registered so the field positions can transition on hover. */
@property --x1 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y1 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --x2 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y2 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --x3 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y3 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
.research__line { display: block; }
.research__title { grid-column: 1 / -1; }
.research__intro { grid-column: 1 / -1; max-width: var(--measure); }
@media (min-width: 48rem) {
.research__title { grid-column: 1 / span 7; }
.research__intro { grid-column: 9 / span 4; align-self: end; }
}
.research__list {
margin-top: var(--space-8);
display: grid;
gap: var(--gap);
}
@media (min-width: 40rem) { .research__list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .research__list { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.paper {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
border: var(--hairline) solid var(--line);
text-decoration: none;
transition: border-color var(--dur-fast) var(--ease);
}
.paper:hover { border-color: var(--ink-muted); }
.paper:hover .paper__title {
text-decoration: underline;
text-decoration-thickness: var(--hairline);
text-underline-offset: 0.14em;
}
.paper__field {
--x1: var(--ax1); --y1: var(--ay1);
--x2: var(--ax2); --y2: var(--ay2);
--x3: var(--ax3); --y3: var(--ay3);
position: relative;
aspect-ratio: 316 / 170;
overflow: hidden;
background-color: var(--night);
background-image:
radial-gradient(80% 110% at var(--x1) var(--y1), var(--c1), transparent 70%),
radial-gradient(70% 100% at var(--x2) var(--y2), var(--c2), transparent 68%),
radial-gradient(95% 130% at var(--x3) var(--y3), var(--c3), transparent 72%);
transition:
--x1 var(--dur-slow) var(--ease), --y1 var(--dur-slow) var(--ease),
--x2 var(--dur-slow) var(--ease), --y2 var(--dur-slow) var(--ease),
--x3 var(--dur-slow) var(--ease), --y3 var(--dur-slow) var(--ease);
}
/* Hover answers the pointer: the fields drift a little, nothing lifts or glows. */
.paper:hover .paper__field {
--x1: calc(var(--ax1) + 7%); --y1: calc(var(--ay1) - 6%);
--x2: calc(var(--ax2) - 6%); --y2: calc(var(--ay2) + 7%);
--x3: calc(var(--ax3) + 5%); --y3: calc(var(--ay3) + 5%);
}
/* Film grain over the colour. */
.paper__field::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
opacity: 0.6;
}
/* The Chladni lines: a 3:1 canvas centred on the field and cropped by it, so the figure keeps
its proportions at every card width. Stroke colour comes from `color`. */
.paper__lines {
position: absolute;
top: 0;
left: 50%;
height: 100%;
aspect-ratio: 3 / 1;
transform: translateX(-50%);
color: color-mix(in srgb, var(--paper) 72%, transparent);
pointer-events: none;
}
.paper__year {
position: absolute;
top: var(--space-3);
left: var(--space-3);
z-index: 1;
padding: var(--space-1) var(--space-2);
background: var(--paper);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
line-height: 1;
}
.paper__body {
display: grid;
align-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
}
.paper__title {
font-size: var(--step-body);
font-weight: var(--weight-medium);
line-height: var(--leading-small);
letter-spacing: 0;
}
/* Controls: segmented rows (.choice-group, base.css). */
.research__controls {
margin-top: var(--space-7);
display: grid;
gap: var(--space-4);
}
.research__list { margin-top: var(--space-6); scroll-margin-top: calc(var(--header-height) + var(--space-5)); }
.research__item[hidden] { display: none; }
.paper__meta {
padding-top: var(--space-3);
border-top: var(--hairline) solid var(--line);
}
.research__foot {
margin-top: var(--space-6);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
}
.research__more {
padding: var(--space-3) var(--space-5);
border: var(--hairline) solid var(--ink);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
text-transform: uppercase;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.research__more:hover { background: var(--ink); color: var(--paper); }
</style>
<script>
import { mountChladni, type ChladniParams } from '../../scripts/chladni';
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Figures draw when their card comes near the viewport: 39 canvases, most never seen. */
const lazy = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const canvas = entry.target as HTMLCanvasElement;
lazy.unobserve(canvas);
const card = canvas.closest<HTMLElement>('.paper');
if (card) mountChladni(canvas, card, JSON.parse(canvas.dataset.chladni!) as ChladniParams, reduce);
}
}, { rootMargin: '300px 0px' });
document.querySelectorAll<HTMLCanvasElement>('[data-chladni]').forEach((c) => lazy.observe(c));
/* Sort, filter, fold. The server renders every card most cited first; this only re-orders and
hides list items, so links, figures and focus stay intact. */
const list = document.querySelector<HTMLElement>('[data-research-list]');
const controls = document.querySelector<HTMLElement>('[data-research-controls]');
const more = document.querySelector<HTMLButtonElement>('[data-research-more]');
const count = document.querySelector<HTMLElement>('[data-research-count]');
if (list && controls && more && count) {
const items = [...list.querySelectorAll<HTMLElement>('.research__item')];
const initial = Number(list.dataset.initial) || 9;
const labels = JSON.parse(more.dataset.labels!) as { showAllTpl: string; showFewer: string; count: string };
let expanded = false;
controls.hidden = false;
const value = (name: string) => controls.querySelector<HTMLInputElement>(`input[name="${name}"]:checked`)?.value ?? '';
const compare: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
cites: (a, b) => Number(b.dataset.cites) - Number(a.dataset.cites) || Number(a.dataset.order) - Number(b.dataset.order),
newest: (a, b) => (b.dataset.date ?? '').localeCompare(a.dataset.date ?? '') || Number(b.dataset.cites) - Number(a.dataset.cites),
oldest: (a, b) => (a.dataset.date ?? '').localeCompare(b.dataset.date ?? '') || Number(b.dataset.cites) - Number(a.dataset.cites),
};
const render = () => {
const sort = value('research-sort') || 'cites';
const category = value('research-category');
const matching = items.filter((li) => !category || li.dataset.categories!.split('|').includes(category));
matching.sort(compare[sort]);
const visible = expanded ? matching : matching.slice(0, initial);
for (const li of items) li.hidden = true;
for (const li of matching) { list.append(li); }
for (const li of visible) li.hidden = false;
count.textContent = labels.count.replace('{shown}', String(visible.length)).replace('{total}', String(matching.length));
more.hidden = matching.length <= initial;
more.textContent = expanded ? labels.showFewer : labels.showAllTpl.replace('{n}', String(matching.length));
more.setAttribute('aria-expanded', String(expanded));
};
controls.addEventListener('change', () => { expanded = false; render(); });
more.addEventListener('click', () => {
expanded = !expanded;
render();
if (!expanded) list.scrollIntoView({ block: 'start' });
});
render();
}
</script>
+145
View File
@@ -0,0 +1,145 @@
---
import Schematic from './Schematic.astro';
import { overview } from './diagrams';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
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. */
const now = 0;
---
<section class="section track" aria-labelledby="track-title">
<div class="container grid">
<div class="track__head">
<h2 id="track-title" class="h2">{s.track.title}</h2>
<p class="muted">{s.track.body}</p>
</div>
<ol class="track__steps" role="list">
{s.track.steps.map((step, i) => (
<li class="track__step" data-state={i < now ? 'past' : i === now ? 'now' : 'next'} aria-current={i === now ? 'step' : undefined}>
<span class="track__node" aria-hidden="true" />
<p class="track__state">{step.state}</p>
<h3 class="h3 track__name">{step.name}</h3>
<p class="track__body muted">{step.body}</p>
</li>
))}
</ol>
</div>
</section>
<section class="section spec" aria-labelledby="spec-title">
<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>
/* Release path: one hairline, one node per phase. */
.track__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
.track__head p { max-width: 24em; }
.track__steps { grid-column: 1 / -1; display: grid; gap: var(--space-7); }
.track__step {
position: relative;
display: grid;
gap: var(--space-2);
align-content: start;
padding-left: var(--space-7);
}
/* Vertical rail on small screens. */
.track__step::before {
content: '';
position: absolute;
left: calc(0.5rem - var(--hairline) / 2);
top: 1.25rem;
bottom: calc(-1 * var(--space-7));
border-left: var(--hairline) solid var(--ink-muted);
}
.track__step:last-child::before { bottom: 0; border-left-style: dashed; }
.track__step[data-state='next']::before { border-left-style: dashed; border-color: var(--line); }
.track__node {
position: absolute;
left: 0;
top: 0.2rem;
width: 1rem;
height: 1rem;
border: var(--hairline) solid var(--ink);
border-radius: 50%;
background: var(--paper);
}
.track__step[data-state='now'] .track__node { outline: var(--hairline) solid var(--ink-muted); outline-offset: 0.35rem; }
.track__step[data-state='now'] .track__node::after {
content: '';
position: absolute;
inset: 0.2rem;
border-radius: 50%;
background: var(--signal);
outline: var(--hairline) solid var(--ink);
}
.track__step[data-state='next'] .track__node { border-style: dashed; border-color: var(--ink-muted); }
.track__state { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
.track__step[data-state='next'] .track__name { color: var(--ink-muted); }
.track__body { max-width: 22em; }
/* 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) {
.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. */
@media (min-width: 64rem) {
.track__steps { grid-column: 5 / span 8; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap); }
.track__step { padding-left: 0; padding-top: var(--space-7); padding-right: var(--space-4); }
.track__step::before {
left: 1.25rem;
right: calc(-1 * var(--gap));
top: calc(0.5rem - var(--hairline) / 2);
bottom: auto;
border-left: 0;
border-top: var(--hairline) solid var(--ink-muted);
}
.track__step:last-child::before { right: 0; border-top-style: dashed; }
.track__step[data-state='next']::before { border-top-style: dashed; border-color: var(--ink-muted); }
.track__node { top: 0; }
}
</style>
-1
View File
@@ -166,7 +166,6 @@ const groups = v.groups.map((g, i) => ({ title: g.title, body: g.body, desc: g.d
right: calc(-1 * (var(--gap) + 200%));
width: 200%;
padding: var(--space-6);
border: var(--hairline) solid var(--line);
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-slow) var(--ease);
+12 -2
View File
@@ -41,6 +41,12 @@ layout.edges.forEach((e) => seen(e.group));
layout.texts?.forEach((t) => seen(t.group));
const freeEdges = layout.edges.filter((e) => !e.group).map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: i * 150 }));
/* A group is lit by its own hover and by the hover of any group that names it in `layout.lit`. */
const litBy = (key: string) => [key, ...Object.entries(layout.lit ?? {}).filter(([, ks]) => ks.includes(key)).map(([k]) => k)].join(' ');
const hoverCss = Object.keys(layout.lit ?? {})
.map((k) => `.schematic:has(.schematic__group[data-group="${k}"]:hover) .schematic__group[data-lit~="${k}"] { --dim: 1; }`)
.join('');
const groups = order.map((g, gi) => {
const start = 320 + gi * 200;
const nodes = layout.nodes.filter((n) => n.group === g);
@@ -48,6 +54,7 @@ const groups = order.map((g, gi) => {
const texts = (layout.texts ?? []).filter((t) => t.group === g);
return {
key: g,
lit: litBy(g),
rings: nodes.filter((n) => n.ring).map((n) => ({ x: n.x, y: n.y, r: n.ring!, d: start })),
edges: edges.map((e, i) => ({ e, points: pts(e), tick: e.tick ? edgeTick(e.points) : null, d: start + 80 + i * 45 })),
dots: nodes.filter((n) => n.dot !== 'none').map((n, i) => ({ x: n.x, y: n.y, sub: n.dot === 'sub', d: start + 120 + i * 45 })),
@@ -82,7 +89,7 @@ const fontSub = layout.fontSub ?? 15;
</g>
{groups.map((g) => (
<g class="schematic__group" data-group={g.key}>
<g class="schematic__group" data-group={g.key} data-lit={g.lit}>
<g fill="none" stroke="currentColor" stroke-width={stroke} vector-effect="non-scaling-stroke">
{g.rings.map((r) => <circle class="s-ring" cx={r.x} cy={r.y} r={r.r} stroke-dasharray="2 5" style={`--d: ${r.d}ms`} />)}
{g.edges.map((f) => (
@@ -154,8 +161,11 @@ const fontSub = layout.fontSub ?? 15;
/* Hover singles out one node and what belongs to it. */
@media (hover: hover) and (min-width: 64rem) {
.schematic__group, .schematic__edges, .schematic__free { transition: opacity var(--dur) var(--ease); }
.schematic:has(.schematic__group:hover) .schematic__group:not(:hover) { opacity: 0.28; }
.schematic:has(.schematic__group:hover) .schematic__group:not(:hover) { opacity: var(--dim, 0.28); }
.schematic:has(.schematic__group:hover) .schematic__edges,
.schematic:has(.schematic__group:hover) .schematic__free { opacity: 0.45; }
}
</style>
<!-- Cross-group hovers from `layout.lit`: written out here because the group keys are data. -->
{hoverCss && <style is:inline set:html={`@media (hover: hover) and (min-width: 64rem) { ${hoverCss} }`} />}
+246
View File
@@ -0,0 +1,246 @@
---
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const s = ui.verifier.soon;
const n = s.notify;
/* The background: a relief of thin ridge lines, computed once at build time. Each line is a row
of smooth value noise, lifted most on the right where the page is quiet and nearly flat under
the text on the left. Rows are drawn back to front and filled with night, so a nearer ridge
hides the one behind it. Colour runs through the logo blues, deep at the back to cyan in front. */
const W = 1600, H = 1000, rows = 46, steps = 160;
const hash = (x: number, y: number) => {
const h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return h - Math.floor(h);
};
const smooth = (t: number) => t * t * (3 - 2 * t);
const value = (x: number, y: number) => {
const xi = Math.floor(x), yi = Math.floor(y), xf = smooth(x - xi), yf = smooth(y - yi);
const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
return a + (b - a) * xf + (c - a) * yf + (a - b - c + d) * xf * yf;
};
const fbm = (x: number, y: number) => value(x, y) * 0.6 + value(x * 2.1 + 5, y * 2.1) * 0.28 + value(x * 4.3 + 9, y * 4.3) * 0.12;
const ridges = Array.from({ length: rows }, (_, r) => {
const depth = r / (rows - 1);
const base = H * 0.22 + depth * H * 0.78;
const pts = Array.from({ length: steps + 1 }, (_, i) => {
const x = (i / steps) * W;
const u = x / W;
const envelope = Math.exp(-((u - 0.7) ** 2) / 0.045) * (0.35 + 0.65 * Math.sin(depth * Math.PI));
const lift = Math.max(0, fbm(u * 5.5, r * 0.22) - 0.3) * 480 * envelope;
return `${x.toFixed(1)},${(base - lift).toFixed(1)}`;
});
return {
d: `M${pts.join(' L')} L${W},${H} L0,${H} Z`,
mix: Math.round(depth * 100),
alpha: (0.35 + depth * 0.5).toFixed(2),
};
});
---
<section class="shero night" aria-labelledby="shero-title">
<div class="shero__media" aria-hidden="true">
<svg class="shero__relief" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg">
{ridges.map((r) => (
<path d={r.d} style={`--mix: ${r.mix}%; --alpha: ${r.alpha}`} />
))}
</svg>
<div class="shero__overlay"></div>
</div>
<div class="container shero__inner">
<!-- First in the document, last on screen: the product name, set across the full width. -->
<h1 id="shero-title" class="shero__name">{s.title[0].replace(/\.$/, '')}<span class="visually-hidden">. {s.title[1]}</span></h1>
<div class="shero__text">
<p class="eyebrow">{s.eyebrow}</p>
<p class="shero__soon" aria-hidden="true">{s.title[1]}</p>
<p class="shero__lead">{s.lead}</p>
</div>
<form class="signup hud-frame" id="notify" action="/notify" method="post" lang={lang} aria-labelledby="notify-title">
<div class="signup__head">
<h2 id="notify-title" class="signup__title">{n.title}</h2>
<p class="signup__body">{n.body}</p>
</div>
<input type="hidden" name="lang" value={lang} />
<input type="hidden" name="list" value="cipherverifier-alpha" />
<p class="visually-hidden" aria-hidden="true">
<label for="notify-website">{ui.contact.form.honeypot}</label>
<input type="text" id="notify-website" name="website" tabindex="-1" autocomplete="off" />
</p>
<div class="signup__field">
<label class="signup__label" for="notify-email">{n.email}</label>
<input class="signup__input" id="notify-email" name="email" type="email" autocomplete="email" placeholder={n.placeholder} required />
</div>
<div class="signup__consent">
<input id="notify-consent" name="consent" type="checkbox" value="yes" required />
<label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label>
</div>
<button class="button signup__submit" type="submit">{n.submit}</button>
</form>
<!-- Status strip: the menu bar's HUD language, set along the foot of the hero. -->
<dl class="readout hud-frame" aria-label={s.status.label}>
<div class="readout__cell readout__cell--head">{s.status.label}</div>
{s.status.rows.map((r) => (
<div class="readout__cell">
<dt>{r.key}</dt>
<dd>{'live' in r && r.live && <span class="readout__live" aria-hidden="true" />}{r.value}</dd>
</div>
))}
</dl>
</div>
</section>
<style>
.shero {
position: relative;
/* Runs up under the floating header, as the home hero does, so the bar takes the night tone. */
margin-top: calc(-1 * var(--header-height));
overflow: hidden;
}
.shero__media { position: absolute; inset: 0; animation: shero-settle var(--dur-hero) var(--ease) both; transform-origin: 60% 50%; }
.shero__relief { width: 100%; height: 100%; display: block; }
.shero__relief path {
fill: var(--night);
stroke: color-mix(in oklab, var(--logo-deep), var(--logo-cyan) var(--mix));
stroke-opacity: var(--alpha);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
/* Darker on the left, where the type sits. */
.shero__overlay {
position: absolute;
inset: 0;
background: linear-gradient(to right, rgb(14 16 15 / 0.7), rgb(14 16 15 / 0) 60%);
}
.shero__inner {
position: relative;
display: grid;
gap: var(--space-8);
padding-top: calc(var(--header-height) + var(--space-7));
min-height: 100svh;
align-content: end;
}
.shero__text { display: grid; gap: var(--space-4); justify-items: start; }
.shero__soon {
font-size: var(--step-h2);
letter-spacing: var(--track-h2);
line-height: var(--leading-h2);
font-weight: var(--weight-medium);
}
.shero__lead { max-width: 26em; color: var(--ink-muted); }
/* The name: one word across the container, its lower edge cut by the section. */
.shero__name {
order: 9;
font-size: var(--step-giant);
font-weight: var(--weight-medium);
letter-spacing: var(--track-display);
line-height: 0.8;
white-space: nowrap;
margin-bottom: -0.1em;
margin-left: -0.05em;
}
/* The one load moment: the image settles, then the type arrives line by line. */
@keyframes shero-settle { from { transform: scale(1.045); } to { transform: none; } }
.shero__soon, .shero__lead, .signup, .readout, .shero__name {
opacity: 0;
animation: shero-in var(--dur-slow) var(--ease) forwards;
}
.shero__name { animation-duration: 900ms; animation-delay: 200ms; }
.shero__soon { animation-delay: 500ms; }
.shero__lead { animation-delay: 640ms; }
.signup, .readout { animation-delay: 780ms; }
@keyframes shero-in { to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.shero__media { animation: none; }
.shero__soon, .shero__lead, .signup, .readout, .shero__name { animation: none; opacity: 1; }
}
/* Sign-up: a framed instrument panel. */
.signup {
--hud-line: var(--hud-line-on-night);
display: grid;
gap: var(--space-5);
padding: var(--space-6);
background: var(--glass-on-night);
align-content: start;
justify-items: start;
}
.signup__head { display: grid; gap: var(--space-2); }
.signup__title { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); font-weight: var(--weight-medium); }
.signup__body { color: var(--ink-muted); }
.signup__field { display: grid; gap: var(--space-2); width: 100%; }
.signup__label { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
.signup__input {
width: 100%;
min-width: 0;
background: transparent;
border: 0;
border-bottom: var(--hairline) solid var(--line);
border-radius: 0;
padding: var(--space-3) 0;
transition: border-color var(--dur-fast) var(--ease);
}
.signup__input::placeholder { color: var(--ink-muted); opacity: 0.7; }
.signup__input:focus-visible { border-bottom-color: currentColor; }
.signup__consent {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: start;
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted);
}
.signup__consent input { width: 1.1em; height: 1.1em; margin: 0.15em 0 0; accent-color: var(--paper); }
.signup__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
/* Readout: stacked on small screens, one strip of cells on wide ones. */
.readout {
--hud-line: var(--hud-line-on-night);
display: grid;
font-family: var(--font-mono);
font-size: var(--step-micro);
letter-spacing: var(--track-caps);
text-transform: uppercase;
line-height: var(--leading-small);
background: var(--glass-on-night);
}
.readout__cell {
display: grid;
grid-template-columns: 8rem 1fr;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.readout__cell + .readout__cell { border-top: var(--hairline) solid var(--line-on-night); }
.readout__cell--head, .readout dt { color: var(--ink-muted); }
.readout dd { display: flex; align-items: center; gap: var(--space-2); }
.readout__live { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--signal); flex: none; }
@media (max-width: 30rem) {
.signup { padding: var(--space-5); }
}
/* Wide: the line and lead on the left, sign-up on the right, the strip, then the name. */
@media (min-width: 64rem) {
.shero__inner {
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--gap);
row-gap: var(--space-6);
align-items: end;
}
.signup { padding: var(--space-5) var(--space-6); gap: var(--space-4); }
.shero__text { grid-column: 1 / span 6; }
.signup { grid-column: 9 / span 4; }
.readout { grid-column: 1 / -1; grid-template-columns: 1fr 2fr 2fr 2fr; }
.shero__name { grid-column: 1 / -1; margin-top: var(--space-4); }
.readout__cell { grid-template-columns: 1fr; gap: var(--space-1); padding: var(--space-4) var(--space-5); }
.readout__cell + .readout__cell { border-top: 0; border-left: var(--hairline) solid var(--line-on-night); }
}
</style>
+8 -4
View File
@@ -13,7 +13,9 @@ export interface Node { x: number; y: number; label?: Label; dot?: 'main' | 'sub
export interface Edge { points: Pt[]; tick?: boolean; faint?: boolean; dashed?: boolean; group?: string }
export interface Box { x: number; y: number; w: number; h: number; dashed?: boolean }
export interface Text { x: number; y: number; text: Label; anchor?: 'start' | 'end' | 'middle'; strong?: boolean; group?: string }
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number }
/* `lit` names, per group, the other groups a hover on it keeps lit: a path that leaves one node
and ends at another belongs to both. */
export interface Layout { w: number; h: number; nodes: Node[]; edges: Edge[]; boxes?: Box[]; texts?: Text[]; fontMain?: number; fontSub?: number; lit?: Record<string, string[]> }
export interface Pair { wide: Layout; tall: Layout }
const RING = 36;
@@ -124,11 +126,13 @@ export function verdict(g: Groups[2]): Pair {
nodes: [dot(140, y, L.finding, 'finding'), ...context.nodes, ...ctx.nodes, ...s1.nodes, ...s2.nodes, ...tools.nodes, ...v.nodes, ...outcomes.nodes],
edges: [
...ctx.edges, ...tools.edges, ...outcomes.edges,
...chain([[140, y], [380, y], [640, y], [920, y], [1220, y]]),
{ points: [[640, y], [640, top], [1220, top], [1220, y]], tick: true },
...chain([[140, y], [380, y], [640, y], [920, y]]),
{ points: [[920, y], [1220, y]], tick: true, group: 'escalation' },
{ points: [[640, y], [640, top], [1220, top], [1220, y]], tick: true, group: 'static' },
],
texts: [...v.texts, { x: 930, y: top - 12, text: L.certain, anchor: 'middle' }, { x: 780, y: y + 30, text: splitTwo(L.unsure), anchor: 'middle' }],
texts: [...v.texts, { x: 930, y: top - 12, text: L.certain, anchor: 'middle', group: 'static' }, { x: 780, y: y - 52, text: splitTwo(L.unsure), anchor: 'middle' }],
fontMain: 19, fontSub: 17,
lit: { static: ['verdict'], escalation: ['verdict'], verdict: ['static'] },
};
const tall = spine([
{ title: L.finding },
+1 -1
View File
@@ -3,7 +3,7 @@ export interface Group<L> { title: string; body: string; desc: string; labels: L
export interface OverviewLabels { repo: string; sbom: string[]; sbomSub: string; dt: string[]; dtSub: string; sast: string[]; sastSub: string; poly: string[]; polySub: string; prism: string[]; prismSub: string; report: string }
export interface ScannersLabels { one: string; oneSub: string; cwe: string[]; cweSub: string; merge: string[]; mergeSub: string; resolve: string[]; resolveSub: string }
export interface VerdictLabels { finding: string; context: string; ctx: string[]; stage1: string[]; stage2: string; tools: string[]; verdict: string; outcomes: string[]; carries: string; certain: string; unsure: string }
export interface VerdictLabels { finding: string; context: string; ctx: string[]; stage1: string[]; stage2: string[]; tools: string[]; verdict: string; outcomes: string[]; carries: string; certain: string; unsure: string }
export interface DependenciesLabels { syft: string; cdxgen: string; sbom: string; sbomSub: string[]; dt: string; dtSub: string[]; vdr: string[]; vdrSub: string; report: string }
export interface OperateLabels { hub: string; sources: string[]; outputs: string[]; scopes: string[]; scopesTitle: string }
+34 -4
View File
@@ -15,10 +15,40 @@ const insights = defineCollection({
readingTime: z.number().int().positive(),
author: z.string(),
authorRole: z.string(),
cover: image(),
coverAlt: z.string(),
/* Optional: without a cover the cards show a colour field (src/components/Field.astro)
and the essay page opens without an image. */
cover: image().optional(),
coverAlt: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
}).refine((d) => !d.cover || d.coverAlt, { message: 'coverAlt is required with a cover', path: ['coverAlt'] }),
});
export const collections = { insights };
/* Mentions: short references to posts elsewhere (LinkedIn first) that CipherCell took part in.
Each gets a short page under /insights/<slug> with the post embedded on request. Query and
fragment are dropped from the URL, so tracking and member IDs from a share link never reach
the site. `embed` is derived from a LinkedIn post URL; set it by hand only if that fails. */
const mentions = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/mentions' }),
schema: z.object({
title: z.string(),
summary: z.string(),
url: z.url().transform((u) => {
const clean = new URL(u);
clean.search = '';
clean.hash = '';
return clean.toString();
}),
source: z.string(),
platform: z.string().default('LinkedIn'),
embed: z.url().optional(),
embedHeight: z.number().int().positive().default(720),
date: z.coerce.date(),
lang: z.enum(['en', 'de']),
}).transform((d) => {
const activity = d.url.match(/activity[-:](\d+)/)?.[1];
const embed = d.embed ?? (activity ? `https://www.linkedin.com/embed/feed/update/urn:li:activity:${activity}` : undefined);
return { ...d, embed };
}),
});
export const collections = { insights, mentions };
@@ -0,0 +1,9 @@
---
title: 'CipherCell at the Berlin 6G Conference 2026.'
summary: 'xG-Incubator announces Florian Frank, Marc Schlotzhauer and Felix Klement at StartUpConnect booth 20, with a demo that analyses software for telecommunications services statically, automatically and deterministically, air-gapped in the customer''s infrastructure. Felix Klement also joins the panel "AI for Trustworthiness".'
url: 'https://www.linkedin.com/posts/xg-incubator_ciphercell-berlin-6g-conference-2026-activity-7499042305199366144-lS85'
source: 'xG-Incubator'
platform: 'LinkedIn'
date: 2026-08-29
lang: en
---
@@ -0,0 +1,9 @@
---
title: 'Component security testing at the xG-ALOE Plugfest 2026.'
summary: 'xG-ALOE welcomes Felix Klement and Florian Frank of the University of Passau to the Plugfest in Berlin. They show a security testing platform for xG software: deterministic vulnerability detection with targeted AI support, with a focus on O-RAN.'
url: 'https://www.linkedin.com/posts/xg-aloe_xgaloe-plugfest2026-openran-activity-7506238009315454976-HZPG'
source: 'xG-ALOE'
platform: 'LinkedIn'
date: 2026-09-17
lang: en
---
@@ -0,0 +1,9 @@
---
title: 'Two days on 5G and xG security in Nuremberg.'
summary: 'At the xG-ALOE User Forum 2026, hosted by Fraunhofer IIS, Felix Klement and Heiner Grottendieck of the BSI led the tutorial "Securing the Evolution to xG" on the key security challenges of next-generation networks.'
url: 'https://lnkd.in/p/eMDeMETT'
source: 'CipherCell'
platform: 'LinkedIn'
date: 2026-06-26
lang: en
---
@@ -0,0 +1,9 @@
---
title: 'Tutorial B2 at the xG-ALOE User Forum 2026.'
summary: 'xG-ALOE announces the joint tutorial "Securing the Evolution to xG" by Felix Klement (University of Passau) and Heiner Grottendieck (BSI): current threat landscapes, security challenges in open telco architectures, and ways to strengthen network resilience. Fraunhofer IIS, Nuremberg, 24 and 25 June 2026.'
url: 'https://www.linkedin.com/posts/xg-aloe_b2-5g-xg-activity-7459863696824262656-QRIN'
source: 'xG-ALOE'
platform: 'LinkedIn'
date: 2026-05-12
lang: en
---
+5
View File
@@ -0,0 +1,5 @@
/* Site-wide switches. Flip a flag and rebuild; nothing else needs to change. */
/* While true, /cipherverifier shows the coming-soon page with the alpha sign-up instead of the
full product page. The full page (src/components/verifier/*) is kept as it is. */
export const verifierComingSoon = true;
+21
View File
@@ -0,0 +1,21 @@
/* The Insights feed: essays (own pages) and mentions (links out), newest first.
One list so the index and the home page sort and slice them the same way. */
import { getCollection, type CollectionEntry } from 'astro:content';
export type FeedItem =
| { kind: 'post'; date: Date; entry: CollectionEntry<'insights'> }
| { kind: 'mention'; date: Date; entry: CollectionEntry<'mentions'> };
export async function getFeed(): Promise<FeedItem[]> {
const posts = (await getCollection('insights')).map((entry) => ({ kind: 'post' as const, date: entry.data.date, entry }));
const mentions = (await getCollection('mentions')).map((entry) => ({ kind: 'mention' as const, date: entry.data.date, entry }));
return [...posts, ...mentions].sort((a, b) => b.date.valueOf() - a.date.valueOf());
}
/* The filter an item belongs to on the Insights index: essays, LinkedIn, or any other platform. */
export type FeedType = 'essay' | 'linkedin' | 'other';
export const feedTypes: FeedType[] = ['essay', 'linkedin', 'other'];
export function feedType(item: FeedItem): FeedType {
if (item.kind === 'post') return 'essay';
return item.entry.data.platform.toLowerCase() === 'linkedin' ? 'linkedin' : 'other';
}
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
{
"profile": "https://scholar.google.com/citations?user=MRMV2cwAAAAJ&hl=en",
"fetchedAt": "2026-10-03",
"entries": [
{
"title": "Open or not open: Are conventional radio access networks more secure and trustworthy than Open-RAN?",
"authors": "F Klement, S Katzenbeisser, V Ulitzsch, J Krämer, S Stanczak, Z Utkovski, ...",
"venue": "arXiv preprint arXiv:2204.12227, 2022",
"cites": 35,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:d1gkVwhDpl0C"
},
{
"title": "Toward securing the 6G transition: A comprehensive empirical method to analyze threats in O-RAN environments",
"authors": "F Klement, W Liu, S Katzenbeisser",
"venue": "IEEE Journal on Selected Areas in Communications 42 (2), 420-431, 2023",
"cites": 29,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:W7OEmFMy1HYC"
},
{
"title": "Securing the open ran infrastructure: Exploring vulnerabilities in kubernetes deployments",
"authors": "F Klement, A Brighente, M Polese, M Conti, S Katzenbeisser",
"venue": "2024 IEEE 10th International Conference on Network Softwarization (NetSoft …, 2024",
"cites": 21,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:ufrVoPGSRksC"
},
{
"title": "Man-in-the-OBD: A modular, protocol agnostic firewall for automotive dongles to enhance privacy and security",
"authors": "F Klement, HC Pöhls, S Katzenbeisser",
"venue": "International Workshop on Attacks and Defenses for Internet-of-Things, 143-164, 2022",
"cites": 13,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:9yKSN-GCB0IC"
},
{
"title": "Endless subscriptions: open RAN is open to RIC E2 subscription denial of service attacks",
"authors": "F Klement, A Brighente, AK Bhattacharjee, S Cecconello, F Kuipers, ...",
"venue": "2025 IEEE 10th European Symposium on Security and Privacy (EuroS&P), 755-770, 2025",
"cites": 11,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:Se3iqnhoufwC"
},
{
"title": "Segmentation and Filtering Are Still the Gold Standard for Privacy in IoT—An In-Depth STRIDE and LINDDUN Analysis of Smart Homes",
"authors": "HC Pöhls, F Kügler, E Geloczi, F Klement",
"venue": "Future Internet 17 (2), 77, 2025",
"cites": 9,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:roLk4NBRz8UC"
},
{
"title": "Testing physical unclonable functions implemented on commercial off-the-shelf NAND flash memories using programming disturbances",
"authors": "NA Anagnostopoulos, Y Fan, MU Saleem, N Mexis, E Gelóczi, F Klement, ...",
"venue": "2022 IEEE 12th International Conference on Consumer Electronics (ICCE-Berlin …, 2022",
"cites": 8,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:UeHWp8X0CEIC"
},
{
"title": "Change Your Car's Filters: Efficient Concurrent and Multi-Stage Firewall for OBD-II Network Traffic",
"authors": "F Klement, HC Pöhls, S Katzenbeisser",
"venue": "2022 IEEE 27th International Workshop on Computer Aided Modeling and Design …, 2022",
"cites": 7,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:qjMakFHDy7sC"
},
{
"title": "Secure Data-Binding in FPGA-based Hardware Architectures utilizing PUFs",
"authors": "F Frank, M Schmid, F Klement, P Palani, A Weber, EB Kavun, W Xiong, ...",
"venue": "Proceedings of the 19th ACM Asia Conference on Computer and Communications …, 2024",
"cites": 5,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:_FxGoFyzp5QC"
},
{
"title": "One standard to rule them all? Assessing the disruptive potential of jamming attacks on Matter networks",
"authors": "F Klement, E Vorderwülbeke, S Katzenbeisser",
"venue": "2023 IEEE International Workshop on Information Forensics and Security (WIFS …, 2023",
"cites": 5,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:YsMSGLbcyi4C"
},
{
"title": "Ensuring trustworthy automated road vehicles: A software integrity validation approach",
"authors": "D Püllen, F Klement, A Vinel, S Katzenbeisser",
"venue": "2023 IEEE International Automated Vehicle Validation Conference (IAVVC), 1-8, 2023",
"cites": 5,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:Y0pCki6q_DkC"
},
{
"title": "SoK: delegated Security in the Internet of Things",
"authors": "E Geloczi, F Klement, P Struck, S Katzenbeisser",
"venue": "Future Internet 17 (5), 202, 2025",
"cites": 4,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:UebtZRa9Y70C"
},
{
"title": "Unveiling the shadows: An approach towards detection, precise localization, and effective isolation of concealed IoT devices in unfamiliar environments",
"authors": "E Geloczi, HC Pöhls, F Klement, J Posegga, S Katzenbeisser",
"venue": "Proceedings of the 23rd Workshop on Privacy in the Electronic Society, 109-123, 2023",
"cites": 4,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:LkGwnXOMwfcC"
},
{
"title": "Form Without Function: Agent Social Behavior in the Moltbook Network",
"authors": "S Zerhoudi, KG Dastidar, F Klement, A Romazanov, A Einwiller, DH Dang, ...",
"venue": "arXiv preprint arXiv:2604.13052, 2026",
"cites": 3,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:3fE2CSJIrl8C"
},
{
"title": "Private and secure over-the-air multi-party communication",
"authors": "JJ Brune, M Frey, F Klement, I Bjelaković, S Katzenbeisser, S Stańczak",
"venue": "2023 57th Asilomar Conference on Signals, Systems, and Computers, 1697-1701, 2023",
"cites": 3,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:WF5omc3nYNoC"
},
{
"title": "ORCA - An Automated Threat Analysis Pipeline for O-RAN Continuous Development",
"authors": "F Klement, A Brighente, M Polese, M Conti, S Katzenbeisser",
"venue": "arXiv preprint arXiv:2601.13681, 2026",
"cites": 2,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:8k81kl-MbHgC"
},
{
"title": "FlexRIC Subscription DoS Attack",
"authors": "F Klement",
"venue": "",
"cites": 2,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:5nxA0vEk-isC"
},
{
"title": "Secure Stitch: Unveiling the Fabric of Security Patterns for the Internet of Things",
"authors": "E Geloczi, F Klement, E Gründinger, S Katzenbeisser",
"venue": "International Workshop on Security and Trust Management, 65-84, 2023",
"cites": 2,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:zYLM7Y9cAGgC"
},
{
"title": "Keep your Enemies closer: On the minimal Distance of Adversaries when using Channel-based Key Extraction in SISO 6G Systems",
"authors": "F Klement, S Takebuchi, T Arul, S Katzenbeisser",
"venue": "2023 19th International Conference on Wireless and Mobile Computing …, 2023",
"cites": 2,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:IjCSPb-OGe4C"
},
{
"title": "Enhancing Scalability in Smart Device Networks: A Modular Adaptive IoT Framework for Efficient Resource Allocation and System Growth",
"authors": "M Waseem, F Klement",
"venue": "",
"cites": 1,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:0EnyYjriUFMC"
},
{
"title": "O-RAN Security: From Standardization and Threat Modeling to Practical Security Testing",
"authors": "J Moore, AS Abdalla, F Klement, AR Shakya, P Shantharama, V Marojevic",
"venue": "IEEE Open Journal of the Communications Society, 2026",
"cites": 0,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:KlAtU1dfN6UC"
},
{
"title": "6G Research and Innovation Cluster (6G-RIC)-Offene und sichere 6G-Technologien: Weltmarktchance für Deutschland",
"authors": "F Klement, S Katzenbeisser",
"venue": "Hannover: Technische Informationsbibliothek, 2026",
"cites": 0,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:kNdYIx-mwKoC"
},
{
"title": "Strengthening Security Foundations in Next-G Wireless Telecommunication Systems",
"authors": "F Klement",
"venue": "Universität Passau, 2025",
"cites": 0,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:hqOjcs7Dif8C"
},
{
"title": "1. Felix Klement, Korbinian Spielvogel, Henrich C. Pöhls (UP) 2. Konstantinos Fysarakis, Manolis Chatzimpyrros, Thodoris Galousis, Michalis Smyrlis (STS), 3. Nikolaos …",
"authors": "MF ST",
"venue": "",
"cites": 0,
"year": null,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=MRMV2cwAAAAJ&pagesize=100&citation_for_view=MRMV2cwAAAAJ:u-x6o8ySG0sC"
}
]
}
+174
View File
@@ -0,0 +1,174 @@
{
"profile": "https://scholar.google.com/citations?user=o9Gu_FMAAAAJ&hl=en",
"fetchedAt": "2026-10-03",
"entries": [
{
"title": "A security process for the automotive service-oriented software architecture",
"authors": "D Püllen, F Frank, M Christl, W Liu, S Katzenbeisser",
"venue": "IEEE Transactions on Vehicular Technology 73 (4), 5036-5053, 2023",
"cites": 15,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:LkGwnXOMwfcC"
},
{
"title": "Spatial correlation in weak physical unclonable functions: A comprehensive overview",
"authors": "N Mexis, T Arul, NA Anagnostopoulos, F Frank, S Böttger, M Hartmann, ...",
"venue": "2023 26th Euromicro Conference on Digital System Design (DSD), 70-78, 2023",
"cites": 14,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:roLk4NBRz8UC"
},
{
"title": "Using memristor arrays as physical unclonable functions",
"authors": "F Frank, T Arul, NA Anagnostopoulos, S Katzenbeisser",
"venue": "European Symposium on Research in Computer Security, 250-271, 2022",
"cites": 10,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:IjCSPb-OGe4C"
},
{
"title": "CNT-PUFs: Highly robust physical unclonable functions based on carbon nanotubes",
"authors": "S Böttger, F Frank, NA Anagnostopoulos, A Mohamed, M Hartmann, ...",
"venue": "2023 IEEE 23rd International Conference on Nanotechnology (NANO), 1-6, 2023",
"cites": 9,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:eQOLeE2rZwMC"
},
{
"title": "Testing physical unclonable functions implemented on commercial off-the-shelf NAND flash memories using programming disturbances",
"authors": "NA Anagnostopoulos, Y Fan, MU Saleem, N Mexis, E Gelóczi, F Klement, ...",
"venue": "2022 IEEE 12th International Conference on Consumer Electronics (ICCE-Berlin …, 2022",
"cites": 8,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:Tyk-4Ss8FVUC"
},
{
"title": "Abusing commodity DRAMs in IoT devices to remotely spy on temperature",
"authors": "F Frank, W Xiong, NA Anagnostopoulos, A Schaller, T Arul, F Koushanfar, ...",
"venue": "IEEE Transactions on Information Forensics and Security 18, 2991-3005, 2023",
"cites": 7,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:Y0pCki6q_DkC"
},
{
"title": "Investigation of Commercial Off-The-Shelf ReRAM Modules for Use as Runtime-Accessible TRNG",
"authors": "T Arul, N Mexis, AE George, F Frank, NA Anagnostopoulos, ...",
"venue": "2024 27th Euromicro Conference on Digital System Design (DSD), 33-42, 2024",
"cites": 5,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:3fE2CSJIrl8C"
},
{
"title": "Secure Data-Binding in FPGA-based Hardware Architectures utilizing PUFs",
"authors": "F Frank, M Schmid, F Klement, P Palani, A Weber, EB Kavun, W Xiong, ...",
"venue": "Proceedings of the 19th ACM Asia Conference on Computer and Communications …, 2024",
"cites": 5,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:UebtZRa9Y70C"
},
{
"title": "CNT-PUFs: highly robust and heat-tolerant carbon-nanotube-based physical unclonable functions",
"authors": "F Frank, S Böttger, N Mexis, NA Anagnostopoulos, A Mohamed, ...",
"venue": "Nanomaterials 13 (22), 2930, 2023",
"cites": 4,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:YsMSGLbcyi4C"
},
{
"title": "Secure communication via chaotic cryptography",
"authors": "E Gelóczi, N Mexis, NA Anagnostopoulos, F Frank, T Arul, SG Stavrinides, ...",
"venue": "crypto day matters 34, 2022",
"cites": 3,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:9yKSN-GCB0IC"
},
{
"title": "Fuzzy extractors using low-density parity-check codes",
"authors": "N Mexis, NA Anagnostopoulos, T Arul, F Frank, S Katzenbeisser",
"venue": "crypto day matters 33, 2021",
"cites": 3,
"year": 2021,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:d1gkVwhDpl0C"
},
{
"title": "Real-world chaos-based cryptography using synchronised Chua chaotic circuits",
"authors": "E Nazarenko, NA Anagnostopoulos, SG Stavrinides, N Mexis, F Frank, ...",
"venue": "TechRxiv 2023 (0717), 2023",
"cites": 2,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:UeHWp8X0CEIC"
},
{
"title": "A reconfigurable hardware infrastructure for evaluating PUFs in non-volatile memories",
"authors": "F Frank, S Katzenbeisser",
"venue": "HardwareX, e00809, 2026",
"cites": 1,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:KlAtU1dfN6UC"
},
{
"title": "Towards Deterministic DDS Communication for Secure Service-Oriented Software-Defined Vehicles",
"authors": "F Frank, D Püllen, A Kampmann, S Katzenbeisser",
"venue": "International Conference on Availability, Reliability and Security, 186-208, 2025",
"cites": 1,
"year": 2025,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:kNdYIx-mwKoC"
},
{
"title": "A Dedicated Mixed-Signal Characterisation and Testing Framework for Novel Digital Security Circuits That Use Carbon-Nanotube-Based Physical Unclonable Functions",
"authors": "F Frank, NA Anagnostopoulos, S Böttger, S Hermann, T Arul, ...",
"venue": "International Conference on Modern Circuits and Systems Technologies (MOCAST) 11, 2022",
"cites": 1,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:2osOgNQ5qMEC"
},
{
"title": "On the sustainability of lightweight cryptography based on PUFs implemented on NAND Flash memories using programming disturbances",
"authors": "NA Anagnostopoulos, Y Fan, MU Saleem, N Mexis, F Frank, T Arul, ...",
"venue": "arXiv preprint arXiv:2204.02498, 2022",
"cites": 1,
"year": 2022,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:u5HHmVD_uO8C"
},
{
"title": "Integrating physical unclonable functions from novel nanomaterials, circuit elements, and memory technologies into future hardware architectures",
"authors": "F Frank",
"venue": "Universität Passau, 2026",
"cites": 0,
"year": 2026,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:ULOm3_A8WrAC"
},
{
"title": "Securing Next Generation Service-Oriented Vehicle Architectures",
"authors": "F Frank, D Püllen, C Krauss, D Spychalski",
"venue": "autotech. agil, 2024",
"cites": 0,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:4TOpqqG69KYC"
},
{
"title": "Threat Analysis of the autotech. agil Cooperative Intelligent Transport System Ecosystem",
"authors": "F Frank, D Püllen, C Krauss, D Spychalski",
"venue": "autotech. agil, 2024",
"cites": 0,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:_kc_bZDykSQC"
},
{
"title": "Safety & Security Co-Engineering in Autonomous Driving and Future Mobility Systems",
"authors": "N Braun, F Frank, C Krauss, D Spychalski, R Schubert, D Püllen",
"venue": "autotech. agil, 2024",
"cites": 0,
"year": 2024,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:YOwf2qJgpHMC"
},
{
"title": "CNT-PUFs: Highly Robust and Heat-Tolerant Carbon-Nanotube-Based Physical Unclonable Functions for Stable Key Generation",
"authors": "S Böttger, F Frank, NA Anagnostopoulos, N Mexis, A Mohamed, ...",
"venue": "TechRxiv 2023 (0717), 2023",
"cites": 0,
"year": 2023,
"scholarUrl": "https://scholar.google.com/citations?view_op=view_citation&hl=en&user=o9Gu_FMAAAAJ&pagesize=100&citation_for_view=o9Gu_FMAAAAJ:W7OEmFMy1HYC"
}
]
}
+8 -11
View File
@@ -14,18 +14,15 @@ export interface Person {
name: string;
role: RoleKey;
portrait: ImageMetadata;
github?: string;
linkedin?: string;
email: string;
}
export const team: Person[] = [
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix, github: 'https://github.com/fklement', linkedin: 'https://www.linkedin.com/in/felix-klement', email: 'felix.klement@ciphercell.de' },
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian, github: 'https://github.com/FlorianFrank', linkedin: 'https://www.linkedin.com/in/florianfrank93/', email: 'florian.frank@ciphercell.de' },
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander, github: 'https://github.com/AlexanderBraml', linkedin: 'https://www.linkedin.com/in/alexanderbraml/', email: 'alexander.braml@ciphercell.de' },
{ name: 'Erik Foris', role: 'engineer', portrait: erik, github: 'https://github.com/ErikForis', email: 'erik.foris@ciphercell.de' },
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc, email: 'marc.schlotzhauer@ciphercell.de' },
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp, github: 'https://github.com/truePhilipp', linkedin: 'https://www.linkedin.com/in/philipp-mayr/', email: 'philipp.mayr@ciphercell.de' },
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin, email: 'karin.kaufmann@ciphercell.de' },
{ name: 'Valentin Hirte', role: 'studentM', portrait: valentin, email: 'valentin.hirte@ciphercell.de' },
{ name: 'Dr. Felix Klement', role: 'ceo', portrait: felix },
{ name: 'Dr. Florian Frank', role: 'cto', portrait: florian },
{ name: 'Alexander Braml', role: 'engineer', portrait: alexander },
{ name: 'Erik Foris', role: 'engineer', portrait: erik },
{ name: 'Marc Schlotzhauer', role: 'bizdev', portrait: marc },
{ name: 'Philipp Mayr', role: 'engineer', portrait: philipp },
{ name: 'Karin Kaufmann', role: 'studentF', portrait: karin },
{ name: 'Valentin Hirte', role: 'studentM', portrait: valentin },
];
+164 -10
View File
@@ -51,7 +51,6 @@ const en = {
},
hero: {
lines: ['Security for the software', 'everything else runs on.'],
imageAlt: 'An industrial plant at dusk: pipe racks, storage tanks and a lattice mast carrying 5G antennas under an overcast sky.',
},
manifesto: {
lines: ['Security is built in,', 'not bolted on.'],
@@ -97,6 +96,8 @@ const en = {
{ 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: {
eyebrow: 'From the essay',
@@ -115,6 +116,18 @@ const en = {
back: 'All insights',
by: 'By',
inEnglish: 'This essay is available in English.',
filter: { label: 'Category', all: 'All', essay: 'Essays', linkedin: 'LinkedIn', other: 'Other' },
mention: {
category: (platform: string) => `On ${platform}`,
source: (source: string) => `Posted by ${source}`,
inEnglish: 'This post is in English.',
inGerman: 'This post is in German.',
open: (platform: string) => `Open on ${platform}`,
embedTitle: (source: string, platform: string) => `Post by ${source} on ${platform}`,
consent: (platform: string) => `The post is hosted by ${platform}. Loading it here connects your browser to ${platform}, which may set cookies.`,
load: (platform: string) => `Load post from ${platform}`,
privacy: 'Privacy policy',
},
},
contact: {
title: 'Talk to us.',
@@ -179,8 +192,8 @@ const en = {
{
title: 'Two stages of judgement.',
body: 'Polygraph reads every finding with its code, its provenance and the file around it, and returns a verdict with a certainty and a priority. A finding it is unsure about, or a false-positive call it is not certain of, goes to a second agent that can read the repository itself, within a fixed budget of requests and tokens. Every verdict carries its reasoning. A cached verdict is reused only when the code it was based on is unchanged.',
desc: 'A finding is assembled with its context: provenance, code snippet, file header and an optional dataflow trace. A first stage returns a static verdict. Certain verdicts go straight to the result. Uncertain verdicts, and false-positive verdicts below high certainty, are escalated to a second agent that reads files, lists directories and searches the tree within a bounded budget. The result is true positive, false positive or uncertain, with certainty, priority and reasoning.',
labels: { finding: 'Finding', context: 'Context', ctx: ['Provenance', 'Code snippet', 'File header', 'Dataflow trace, optional'], stage1: ['Static', 'verdict'], stage2: 'Escalation', tools: ['Reads files', 'Lists directories', 'Searches the tree', 'Bounded budget'], verdict: 'Verdict', outcomes: ['True positive', 'False positive', 'Uncertain'], carries: 'Certainty, priority, reasoning', certain: 'Certain', unsure: 'Uncertain, or an unsure false positive' },
desc: 'A finding is assembled with its context: provenance, code snippet, file header and an optional dataflow trace. A static verdict agent returns a first verdict. Certain verdicts go straight to the result. Uncertain verdicts, and false-positive verdicts below high certainty, are escalated to an escalation agent that reads files, lists directories and searches the tree within a bounded budget. The result is true positive, false positive or uncertain, with certainty, priority and reasoning.',
labels: { finding: 'Finding', context: 'Context', ctx: ['Provenance', 'Code snippet', 'File header', 'Dataflow trace, optional'], stage1: ['Static verdict', 'Agent'], stage2: ['Escalation', 'Agent'], tools: ['Reads files', 'Lists directories', 'Searches the tree', 'Bounded budget'], verdict: 'Verdict', outcomes: ['True positive', 'False positive', 'Uncertain'], carries: 'Certainty, priority, reasoning', certain: 'Certain', unsure: 'Uncertain, or an unsure false positive' },
},
{
title: 'The dependency side.',
@@ -216,12 +229,56 @@ const en = {
body: 'Bring a repository you know well, and we will show you what CipherVerifier makes of it: what it finds, what it rules out, and why.',
cta: 'Talk to us',
},
soon: {
eyebrow: 'Product',
title: ['CipherVerifier.', 'Coming soon.'],
lead: 'A self-hosted verification pipeline for software. Currently in a closed demonstrator phase.',
status: {
label: 'System status',
rows: [
{ key: 'Phase', value: 'Closed demonstrator', live: true },
{ key: 'Next', value: 'Alpha release' },
{ key: 'Deployment', value: 'Self-hosted' },
],
},
notify: {
title: 'Register for the alpha.',
body: 'One email when the first alpha is released. Nothing else.',
email: 'Work email',
placeholder: 'name@company.com',
consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
privacy: 'Privacy policy',
submit: 'Register',
},
track: {
title: 'Release path.',
body: 'Access opens in stages. Registered addresses hear first when it moves.',
steps: [
{ state: 'Now', name: 'Closed demonstrator', body: 'Running in a closed setting, by invitation only.' },
{ state: 'Next', name: 'Alpha', body: 'The first release outside the demonstrator. Registered addresses are told first.' },
{ state: 'Later', name: 'General availability', body: 'Date to follow.' },
],
},
spec: {
title: 'What is being built.',
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.',
rows: [
{ key: 'Static analysers', value: '17, each in its own container' },
{ key: 'Languages', value: '14, plus Docker, Kubernetes, Helm, Terraform and CloudFormation' },
{ key: 'Bill of materials', value: '2 generators, merged into one CycloneDX document' },
{ key: 'Vocabulary', value: '970 CWE entries' },
{ key: 'Pipeline', value: '5 stages, each switchable per repository' },
{ key: 'Review', value: 'Two-stage verdict on every finding' },
{ key: 'Deployment', value: 'Self-hosted' },
],
figure: 'Figure 1. One run, five stages.',
},
},
},
team: {
title: ['Researchers,', 'engineers,', 'architects.'],
intro: 'The people who go deep on the problems that matter, from network software and private 5G to hardware and cloud-native systems.',
intro: 'The people who go deep on the problems that matter, from network, software to hardware and cloud-native systems. If it\'s a genuinely hard problem, we\'re in our element.',
photoAlt: 'The CipherCell team on a staircase in the offices in Passau.',
links: { github: 'GitHub', linkedin: 'LinkedIn', email: 'Email' },
roles: {
ceo: 'Founder and CEO',
cto: 'Founder and CTO',
@@ -230,6 +287,26 @@ const en = {
studentF: 'Working student, development',
studentM: 'Working student, development',
},
research: {
title: ['Published', 'research.'],
intro: 'Papers by our team on Open RAN, cloud-native infrastructure and hardware security. The method we sell grew out of this work.',
sortLabel: 'Sort',
sort: { cites: 'Most cited', newest: 'Newest', oldest: 'Oldest' },
filterLabel: 'Category',
all: 'All',
showAll: (n: number) => `Show all ${n} papers`,
showFewer: 'Show fewer',
count: (shown: number, total: number) => `Showing ${shown} of ${total} papers`,
categories: {
'Open RAN & 6G': 'Open RAN & 6G',
'Hardware security': 'Hardware security',
'Automotive & mobility': 'Automotive & mobility',
'IoT security & privacy': 'IoT security & privacy',
'Wireless security': 'Wireless security',
'Cryptography': 'Cryptography',
'AI agents': 'AI agents',
} as Record<string, string>,
},
},
notFound: {
title: 'Page not found',
@@ -269,7 +346,6 @@ const de: typeof en = {
},
hero: {
lines: ['Sicherheit für die Software,', 'auf der alles andere läuft.'],
imageAlt: 'Eine Industrieanlage in der Dämmerung: Rohrbrücken, Tanks und ein Gittermast mit 5G-Antennen unter bedecktem Himmel.',
},
manifesto: {
lines: ['Sicherheit wird eingebaut,', 'nicht angeschraubt.'],
@@ -315,6 +391,8 @@ const de: typeof en = {
{ 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: {
eyebrow: 'Aus dem Essay',
@@ -333,6 +411,18 @@ const de: typeof en = {
back: 'Alle Einblicke',
by: 'Von',
inEnglish: 'Dieser Essay liegt auf Englisch vor.',
filter: { label: 'Kategorie', all: 'Alle', essay: 'Essays', linkedin: 'LinkedIn', other: 'Weitere' },
mention: {
category: (platform: string) => `Auf ${platform}`,
source: (source: string) => `Veröffentlicht von ${source}`,
inEnglish: 'Dieser Beitrag ist auf Englisch.',
inGerman: 'Dieser Beitrag ist auf Deutsch.',
open: (platform: string) => `Auf ${platform} öffnen`,
embedTitle: (source: string, platform: string) => `Beitrag von ${source} auf ${platform}`,
consent: (platform: string) => `Der Beitrag liegt bei ${platform}. Wenn Sie ihn hier laden, verbindet sich Ihr Browser mit ${platform}; dabei können Cookies gesetzt werden.`,
load: (platform: string) => `Beitrag von ${platform} laden`,
privacy: 'Datenschutzerklärung',
},
},
contact: {
title: 'Sprechen Sie mit uns.',
@@ -397,8 +487,8 @@ const de: typeof en = {
{
title: 'Urteil in zwei Stufen.',
body: 'Polygraph liest jeden Fund mit seinem Code, seiner Herkunft und der Datei darum herum und gibt ein Urteil mit Sicherheit und Priorität zurück. Ein Fund, bei dem es unsicher ist, oder ein Fehlalarm-Urteil, dessen es sich nicht sicher ist, geht an einen zweiten Agenten, der das Repository selbst lesen kann, innerhalb eines festen Budgets an Anfragen und Tokens. Jedes Urteil trägt seine Begründung. Ein gemerktes Urteil wird nur wiederverwendet, wenn der Code, auf dem es beruht, unverändert ist.',
desc: 'Ein Fund wird mit seinem Kontext zusammengestellt: Herkunft, Code-Ausschnitt, Dateikopf und optional eine Datenfluss-Spur. Eine erste Stufe liefert ein statisches Urteil. Sichere Urteile gehen direkt ins Ergebnis. Unsichere Urteile und Fehlalarm-Urteile unterhalb hoher Sicherheit werden an einen zweiten Agenten eskaliert, der Dateien liest, Verzeichnisse auflistet und den Baum durchsucht, innerhalb eines begrenzten Budgets. Das Ergebnis ist echt, Fehlalarm oder unsicher, mit Sicherheit, Priorität und Begründung.',
labels: { finding: 'Fund', context: 'Kontext', ctx: ['Herkunft', 'Code-Ausschnitt', 'Dateikopf', 'Datenfluss-Spur, optional'], stage1: ['Statisches', 'Urteil'], stage2: 'Eskalation', tools: ['Liest Dateien', 'Listet Verzeichnisse', 'Durchsucht den Baum', 'Begrenztes Budget'], verdict: 'Urteil', outcomes: ['Echt', 'Fehlalarm', 'Unsicher'], carries: 'Sicherheit, Priorität, Begründung', certain: 'Sicher', unsure: 'Unsicher oder unsicherer Fehlalarm' },
desc: 'Ein Fund wird mit seinem Kontext zusammengestellt: Herkunft, Code-Ausschnitt, Dateikopf und optional eine Datenfluss-Spur. Ein Agent für statische Urteile liefert ein erstes Urteil. Sichere Urteile gehen direkt ins Ergebnis. Unsichere Urteile und Fehlalarm-Urteile unterhalb hoher Sicherheit werden an einen Eskalations-Agenten weitergegeben, der Dateien liest, Verzeichnisse auflistet und den Baum durchsucht, innerhalb eines begrenzten Budgets. Das Ergebnis ist echt, Fehlalarm oder unsicher, mit Sicherheit, Priorität und Begründung.',
labels: { finding: 'Fund', context: 'Kontext', ctx: ['Herkunft', 'Code-Ausschnitt', 'Dateikopf', 'Datenfluss-Spur, optional'], stage1: ['Agent für', 'statische Urteile'], stage2: ['Eskalations-', 'Agent'], tools: ['Liest Dateien', 'Listet Verzeichnisse', 'Durchsucht den Baum', 'Begrenztes Budget'], verdict: 'Urteil', outcomes: ['Echt', 'Fehlalarm', 'Unsicher'], carries: 'Sicherheit, Priorität, Begründung', certain: 'Sicher', unsure: 'Unsicher oder unsicherer Fehlalarm' },
},
{
title: 'Die Seite der Abhängigkeiten.',
@@ -434,12 +524,56 @@ const de: typeof en = {
body: 'Bringen Sie ein Repository mit, das Sie gut kennen, und wir zeigen Ihnen, was CipherVerifier daraus macht: was es findet, was es ausschließt, und warum.',
cta: 'Sprechen Sie mit uns',
},
soon: {
eyebrow: 'Produkt',
title: ['CipherVerifier.', 'Demnächst.'],
lead: 'Eine selbst gehostete Verifikations-Pipeline für Software. Derzeit in einer geschlossenen Demonstratorphase.',
status: {
label: 'Systemstatus',
rows: [
{ key: 'Phase', value: 'Geschlossener Demonstrator', live: true },
{ key: 'Als Nächstes', value: 'Alpha-Release' },
{ key: 'Betrieb', value: 'Selbst gehostet' },
],
},
notify: {
title: 'Für die Alpha registrieren.',
body: 'Eine E-Mail, sobald die erste Alpha erscheint. Sonst nichts.',
email: 'Geschäftliche E-Mail',
placeholder: 'name@firma.de',
consent: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
privacy: 'Datenschutzerklärung',
submit: 'Registrieren',
},
track: {
title: 'Weg zum Release.',
body: 'Der Zugang öffnet sich in Stufen. Registrierte Adressen erfahren zuerst, wenn es weitergeht.',
steps: [
{ state: 'Jetzt', name: 'Geschlossener Demonstrator', body: 'Läuft in einem geschlossenen Rahmen, nur auf Einladung.' },
{ state: 'Als Nächstes', name: 'Alpha', body: 'Das erste Release außerhalb des Demonstrators. Registrierte Adressen erfahren es zuerst.' },
{ state: 'Später', name: 'Allgemeine Verfügbarkeit', body: 'Termin folgt.' },
],
},
spec: {
title: 'Was entsteht.',
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.',
rows: [
{ key: 'Statische Analyse', value: '17 Werkzeuge, jedes im eigenen Container' },
{ key: 'Sprachen', value: '14, dazu Docker, Kubernetes, Helm, Terraform und CloudFormation' },
{ key: 'Stückliste', value: '2 Generatoren, zu einem CycloneDX-Dokument zusammengeführt' },
{ key: 'Vokabular', value: '970 CWE-Einträge' },
{ key: 'Pipeline', value: '5 Stufen, jede pro Repository abschaltbar' },
{ key: 'Urteil', value: 'Zweistufig, für jeden Fund' },
{ key: 'Betrieb', value: 'Selbst gehostet' },
],
figure: 'Abbildung 1. Ein Lauf, fünf Stufen.',
},
},
},
team: {
title: ['Forschende,', 'Ingenieure,', 'Architekten.'],
intro: 'Die Menschen, die sich in die Probleme vertiefen, auf die es ankommt: von Netzsoftware und privaten 5G-Netzen bis zu Hardware und Cloud-nativen Systemen.',
intro: 'Die Menschen, die sich in die Probleme vertiefen, auf die es ankommt: von Netzwerken über Software bis hin zu Hardware und Cloud-nativen Systemen. Wenn es sich um ein wirklich schwieriges Problem handelt, sind wir in unserem Element.',
photoAlt: 'Das CipherCell-Team auf einer Treppe in den Büros in Passau.',
links: { github: 'GitHub', linkedin: 'LinkedIn', email: 'E-Mail' },
roles: {
ceo: 'Gründer und CEO',
cto: 'Gründer und CTO',
@@ -448,6 +582,26 @@ const de: typeof en = {
studentF: 'Werkstudentin, Entwicklung',
studentM: 'Werkstudent, Entwicklung',
},
research: {
title: ['Veröffentlichte', 'Forschung.'],
intro: 'Arbeiten unseres Teams zu Open RAN, cloud-nativer Infrastruktur und Hardware-Sicherheit. Die Methode, die wir anbieten, ist aus dieser Arbeit entstanden.',
sortLabel: 'Sortieren',
sort: { cites: 'Meistzitiert', newest: 'Neueste', oldest: 'Älteste' },
filterLabel: 'Kategorie',
all: 'Alle',
showAll: (n: number) => `Alle ${n} Arbeiten zeigen`,
showFewer: 'Weniger zeigen',
count: (shown: number, total: number) => `${shown} von ${total} Arbeiten`,
categories: {
'Open RAN & 6G': 'Open RAN & 6G',
'Hardware security': 'Hardware-Sicherheit',
'Automotive & mobility': 'Automotive & Mobilität',
'IoT security & privacy': 'IoT-Sicherheit & Datenschutz',
'Wireless security': 'Funksicherheit',
'Cryptography': 'Kryptografie',
'AI agents': 'KI-Agenten',
} as Record<string, string>,
},
},
notFound: {
title: 'Seite nicht gefunden',
+104 -25
View File
@@ -1,13 +1,23 @@
---
import { getCollection } from 'astro:content';
import { feedType, feedTypes, getFeed } from '../data/insights';
import { Picture } from 'astro:assets';
import Base from '../layouts/Base.astro';
import Field from '../components/Field.astro';
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const posts = (await getCollection('insights')).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const feed = await getFeed();
const m = ui.insights.mention;
const f = ui.insights.filter;
/* Only filters that have entries; "All" first. */
const filters = [
{ key: 'all', label: f.all, count: feed.length },
...feedTypes
.map((key) => ({ key, label: f[key], count: feed.filter((item) => feedType(item) === key).length }))
.filter((x) => x.count > 0),
];
---
<Base lang={lang} title={ui.meta.insights.title} description={ui.meta.insights.description}>
<section class="section section--intro">
@@ -19,32 +29,70 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
<section class="section--list" aria-label={ui.insights.title}>
<div class="container">
<ul class="posts" role="list">
{posts.map((post) => (
<li class="post">
<a class="post__link" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
<div class="post__media">
<Picture
src={post.data.cover}
alt={post.data.coverAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[400, 640, 960]}
sizes="(min-width: 48rem) 40vw, 100vw"
loading="lazy"
decoding="async"
class="post__img img-doc"
/>
</div>
{filters.length > 2 && (
<fieldset class="choice-group filters" data-filters>
<legend class="choice-group__legend">{f.label}</legend>
<div class="choices">
{filters.map((x) => (
<label class="choice">
<input type="radio" name="insights-type" value={x.key} checked={x.key === 'all'} />
<span>{x.label} <span class="choice__n">{x.count}</span></span>
</label>
))}
</div>
</fieldset>
)}
<ul class="posts" role="list" data-feed>
{feed.map((item) => item.kind === 'post' ? (
<li class="post" data-type={feedType(item)}>
<a class="post__link" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
{item.entry.data.cover ? (
<div class="post__media">
<Picture
src={item.entry.data.cover}
alt={item.entry.data.coverAlt!}
formats={['avif']}
fallbackFormat="webp"
widths={[400, 640, 960]}
sizes="(min-width: 48rem) 40vw, 100vw"
loading="lazy"
decoding="async"
class="post__img img-doc"
/>
</div>
) : (
<Field seed={item.entry.id} class="post__media post__field">
<span class="field__title" aria-hidden="true">{item.entry.data.category}</span>
</Field>
)}
<div class="post__text">
<p class="small muted post__meta">
<span>{post.data.category}</span>
<time datetime={post.data.date.toISOString().slice(0, 10)}>{formatDate(post.data.date, lang)}</time>
<span>{ui.insights.readingTime(post.data.readingTime)}</span>
<span>{item.entry.data.category}</span>
<time datetime={item.date.toISOString().slice(0, 10)}>{formatDate(item.date, lang)}</time>
<span>{ui.insights.readingTime(item.entry.data.readingTime)}</span>
</p>
<h2 class="h2">{post.data.title}</h2>
{post.data.subtitle && <p class="muted">{post.data.subtitle}</p>}
{post.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>}
<h2 class="h2">{item.entry.data.title}</h2>
{item.entry.data.subtitle && <p class="muted">{item.entry.data.subtitle}</p>}
{item.entry.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>}
</div>
</a>
</li>
) : (
<li class="post" data-type={feedType(item)}>
<a class="post__link" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
<Field seed={item.entry.id} class="post__media post__field">
<span class="field__title" aria-hidden="true">{item.entry.data.source}</span>
<span class="field__meta" aria-hidden="true">{item.entry.data.platform}</span>
</Field>
<div class="post__text">
<p class="small muted post__meta">
<span>{m.category(item.entry.data.platform)}</span>
<time datetime={item.date.toISOString().slice(0, 10)}>{formatDate(item.date, lang)}</time>
<span>{m.source(item.entry.data.source)}</span>
</p>
<h2 class="h2">{item.entry.data.title}</h2>
<p class="muted mention__summary">{item.entry.data.summary}</p>
{item.entry.data.lang !== lang && <p class="small muted">{item.entry.data.lang === 'en' ? m.inEnglish : m.inGerman}</p>}
</div>
</a>
</li>
@@ -54,6 +102,29 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
</section>
</Base>
<script>
/* Filter the list by type. The choice lives in ?type= so a filtered view can be linked;
without script every entry shows. */
const bar = document.querySelector<HTMLElement>('[data-filters]');
const items = document.querySelectorAll<HTMLElement>('[data-feed] > li');
if (bar) {
const inputs = bar.querySelectorAll<HTMLInputElement>('input[name="insights-type"]');
const keys = [...inputs].map((i) => i.value);
const apply = (key: string, push: boolean) => {
if (!keys.includes(key)) key = 'all';
inputs.forEach((i) => { i.checked = i.value === key; });
items.forEach((li) => { li.hidden = key !== 'all' && li.dataset.type !== key; });
if (push) {
const url = new URL(location.href);
if (key === 'all') url.searchParams.delete('type'); else url.searchParams.set('type', key);
history.replaceState(null, '', url);
}
};
bar.addEventListener('change', (e) => apply((e.target as HTMLInputElement).value, true));
apply(new URL(location.href).searchParams.get('type') ?? 'all', false);
}
</script>
<style>
.index__title { grid-column: 1 / -1; }
.index__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
@@ -62,6 +133,9 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
.index__lead { grid-column: 1 / span 5; }
}
.section--list { padding-bottom: var(--section); }
/* Filter row: the shared segmented choice (base.css), as on the research list. */
.filters { padding-bottom: var(--space-6); }
.post[hidden] { display: none; }
.posts { border-top: var(--hairline) solid var(--line); }
.post { border-bottom: var(--hairline) solid var(--line); }
.post__link {
@@ -74,6 +148,11 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
.post__img, :global(.post__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }
.post__text { display: grid; gap: var(--space-4); align-content: start; }
.post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
/* No cover (every mention, an essay without one): a colour field names who posted it and where. */
:global(.post__field) { aspect-ratio: 3 / 2; gap: var(--space-2); }
:global(.post__field) .field__title { font-size: var(--step-title); line-height: var(--leading-title); letter-spacing: var(--track-h2); }
:global(.post__field) .field__meta { font-size: var(--step-small); line-height: var(--leading-small); }
.mention__summary { max-width: var(--measure); }
@media (min-width: 48rem) {
.post__link { grid-template-columns: 5fr 7fr; }
}
+109
View File
@@ -0,0 +1,109 @@
---
import type { CollectionEntry } from 'astro:content';
import { render } from 'astro:content';
import Base from '../layouts/Base.astro';
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; mention: CollectionEntry<'mentions'> }
const { lang, mention } = Astro.props;
const ui = t(lang);
const m = ui.insights.mention;
const d = mention.data;
/* Optional notes of our own below the front matter. */
const hasBody = Boolean(mention.body?.trim());
const { Content } = await render(mention);
---
<Base lang={lang} title={d.title} description={d.summary}>
<article class="mention">
<header class="section section--intro">
<div class="container grid">
<div class="mention__back">
<a class="link" href={localePath(lang, '/insights')}>{ui.insights.back}</a>
</div>
<div class="mention__head">
<p class="small muted mention__meta">
<span>{m.category(d.platform)}</span>
<time datetime={d.date.toISOString().slice(0, 10)}>{formatDate(d.date, lang)}</time>
<span>{m.source(d.source)}</span>
</p>
<h1 class="h2 mention__title" lang={d.lang !== lang ? d.lang : undefined}>{d.title}</h1>
<p class="mention__summary muted" lang={d.lang !== lang ? d.lang : undefined}>{d.summary}</p>
{hasBody && <div class="prose mention__notes" lang={d.lang !== lang ? d.lang : undefined}><Content /></div>}
<p><a class="link" href={d.url} rel="noopener">{m.open(d.platform)}</a></p>
</div>
</div>
</header>
{d.embed && (
<section class="mention__embed-section" aria-label={m.embedTitle(d.source, d.platform)}>
<div class="container grid">
<div class="embed hud-frame" data-embed data-src={d.embed} data-title={m.embedTitle(d.source, d.platform)} style={`--embed-h: ${d.embedHeight}px`}>
<div class="embed__gate" data-embed-gate>
<p class="embed__source">{d.source}</p>
<p class="small muted embed__consent">
{m.consent(d.platform)} <a href={localePath(lang, '/privacy')}>{m.privacy}</a>
</p>
<button class="button" type="button" data-embed-load>{m.load(d.platform)}</button>
</div>
</div>
</div>
</section>
)}
</article>
</Base>
<script>
/* Two-click embed: nothing is requested from the platform until the visitor asks for it. */
document.querySelectorAll<HTMLElement>('[data-embed]').forEach((box) => {
const button = box.querySelector<HTMLButtonElement>('[data-embed-load]');
button?.addEventListener('click', () => {
const frame = document.createElement('iframe');
frame.src = box.dataset.src!;
frame.title = box.dataset.title ?? '';
frame.loading = 'lazy';
frame.className = 'embed__frame';
frame.setAttribute('allowfullscreen', '');
box.querySelector('[data-embed-gate]')?.remove();
box.append(frame);
box.toggleAttribute('data-loaded', true);
frame.focus();
});
});
</script>
<style>
.mention__back { grid-column: 1 / -1; }
.mention__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); justify-items: start; }
.mention__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
.mention__title { text-wrap: balance; }
.mention__summary { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); max-width: var(--measure); }
.mention__embed-section { padding-bottom: var(--section); }
.embed {
grid-column: 1 / -1;
display: grid;
aspect-ratio: 4 / 3;
background: var(--paper-deep);
}
/* Loaded: the frame takes the post's height. The jump answers the visitor's own click. */
.embed[data-loaded] { aspect-ratio: auto; }
.embed[data-loaded] { background: var(--paper); }
.embed__gate {
display: grid;
align-content: end;
justify-items: start;
gap: var(--space-5);
padding: var(--space-6);
}
.embed__source { font-size: var(--step-title); line-height: var(--leading-title); letter-spacing: var(--track-h2); color: var(--ink-muted); }
.embed__consent { max-width: 34em; }
.embed__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
.embed :global(.embed__frame) { width: 100%; height: var(--embed-h); border: 0; display: block; }
@media (min-width: 48rem) {
.mention__back { grid-column: 1 / span 2; }
.mention__head { grid-column: 3 / span 8; }
/* Half the grid, centred: columns 4 to 9. */
.embed { grid-column: 4 / span 6; }
}
</style>
+3 -3
View File
@@ -31,10 +31,10 @@ const d = post.data;
</div>
</header>
<div class="container">
{d.cover && <div class="container">
<Picture
src={d.cover}
alt={d.coverAlt}
alt={d.coverAlt!}
formats={['avif']}
fallbackFormat="webp"
widths={[640, 960, 1440, 1920]}
@@ -43,7 +43,7 @@ const d = post.data;
decoding="async"
class="post__cover img-doc"
/>
</div>
</div>}
<div class="section container grid">
<div class="prose post__body">
+14 -22
View File
@@ -3,6 +3,7 @@ import { Picture } from 'astro:assets';
import Base from '../layouts/Base.astro';
import teamPhoto from '../assets/images/team.png';
import { team } from '../data/team';
import Research from '../components/team/Research.astro';
import { t, type Lang } from '../i18n/ui';
interface Props { lang: Lang }
@@ -19,20 +20,21 @@ const ui = t(lang);
</div>
</section>
<div class="team-photo">
<div class="container">
<figure class="team-photo">
<Picture
src={teamPhoto}
alt={ui.team.photoAlt}
formats={['avif']}
fallbackFormat="webp"
widths={[640, 960, 1440]}
sizes="100vw"
widths={[640, 960, 1296, 1920, 2592]}
sizes="(min-width: 90rem) 81rem, calc(100vw - 2 * clamp(1.25rem, 5vw, 5rem))"
loading="eager"
decoding="async"
class="img-cover img-mono team-photo__img"
class="img-mono team-photo__img"
pictureAttributes={{ class: 'team-photo__picture' }}
/>
<div class="team-photo__overlay" aria-hidden="true"></div>
</figure>
</div>
<section class="section" aria-label={ui.nav.team}>
@@ -53,16 +55,13 @@ const ui = t(lang);
/>
<h2 class="h3 person__name">{p.name}</h2>
<p class="muted">{ui.team.roles[p.role]}</p>
<ul class="person__links small" role="list">
{p.github && <li><a href={p.github} rel="noopener">{ui.team.links.github}</a></li>}
{p.linkedin && <li><a href={p.linkedin} rel="noopener">{ui.team.links.linkedin}</a></li>}
<li><a href={`mailto:${p.email}`}>{ui.team.links.email}</a></li>
</ul>
</li>
))}
</ul>
</div>
</section>
<Research lang={lang} />
</Base>
<style>
@@ -74,16 +73,11 @@ const ui = t(lang);
.team-intro__lead { grid-column: 9 / span 4; align-self: end; }
}
.team-photo {
position: relative;
/* Explicit height: an aspect-ratio with max-height would transfer the cap to the width. */
height: min(66.67vw, 80svh);
min-height: 20rem;
overflow: hidden;
}
:global(.team-photo__picture) { position: absolute; inset: 0; }
.team-photo__img, :global(.team-photo__img) { width: 100%; height: 100%; object-fit: cover; object-position: 50% 30%; }
.team-photo__overlay { position: absolute; inset: 0; background: var(--overlay); opacity: 0.6; }
/* Inside the content column at the photo's own proportions: no crop, no zoom, so every pixel
the browser shows exists in the source. Widths go to 2592 for retina screens. */
.team-photo { margin: 0; }
:global(.team-photo__picture) { display: block; }
.team-photo__img, :global(.team-photo__img) { display: block; width: 100%; height: auto; }
.people {
display: grid;
@@ -91,8 +85,6 @@ const ui = t(lang);
}
.person { display: grid; gap: var(--space-2); align-content: start; }
.person__img, :global(.person__img) { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; margin-bottom: var(--space-2); }
.person__links { display: flex; gap: var(--space-4); margin-top: var(--space-2); }
.person__links a { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
@media (min-width: 30rem) { .people { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .people { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
+18 -6
View File
@@ -6,6 +6,9 @@ import HowItWorks from '../components/verifier/HowItWorks.astro';
import Cards from '../components/verifier/Cards.astro';
import Facts from '../components/verifier/Facts.astro';
import Outro from '../components/verifier/Outro.astro';
import SoonHero from '../components/verifier/SoonHero.astro';
import ComingSoon from '../components/verifier/ComingSoon.astro';
import { verifierComingSoon } from '../data/flags';
import { t, type Lang } from '../i18n/ui';
interface Props { lang: Lang }
@@ -13,10 +16,19 @@ const { lang } = Astro.props;
const ui = t(lang);
---
<Base lang={lang} title={ui.meta.verifier.title} description={ui.meta.verifier.description}>
<Hero lang={lang} />
<Intro lang={lang} />
<HowItWorks lang={lang} />
<Cards lang={lang} />
<Facts lang={lang} />
<Outro lang={lang} />
{verifierComingSoon ? (
<>
<SoonHero lang={lang} />
<ComingSoon lang={lang} />
</>
) : (
<>
<Hero lang={lang} />
<Intro lang={lang} />
<HowItWorks lang={lang} />
<Cards lang={lang} />
<Facts lang={lang} />
<Outro lang={lang} />
</>
)}
</Base>
+10 -4
View File
@@ -1,11 +1,17 @@
---
import { getCollection } from 'astro:content';
import Post from '../../_Post.astro';
import Mention from '../../_Mention.astro';
/* Essays and mentions share /insights/<slug>; a slug used by both would be a defect. */
export async function getStaticPaths() {
const posts = await getCollection('insights');
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
const posts = (await getCollection('insights')).map((post) => ({ params: { slug: post.id }, props: { post } }));
const mentions = (await getCollection('mentions')).map((mention) => ({ params: { slug: mention.id }, props: { mention } }));
const taken = new Set(posts.map((p) => p.params.slug));
const clash = mentions.find((m) => taken.has(m.params.slug));
if (clash) throw new Error(`Insights slug used by an essay and a mention: ${clash.params.slug}`);
return [...posts, ...mentions];
}
const { post } = Astro.props;
const { post, mention } = Astro.props;
---
<Post lang="de" post={post} />
{post ? <Post lang="de" post={post} /> : <Mention lang="de" mention={mention!} />}
+6
View File
@@ -17,6 +17,12 @@ const ui = t('de');
<h2>Kontaktaufnahme</h2>
<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>
<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>
<h2>Eingebettete LinkedIn-Beiträge</h2>
<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>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>
+10 -4
View File
@@ -1,11 +1,17 @@
---
import { getCollection } from 'astro:content';
import Post from '../_Post.astro';
import Mention from '../_Mention.astro';
/* Essays and mentions share /insights/<slug>; a slug used by both would be a defect. */
export async function getStaticPaths() {
const posts = await getCollection('insights');
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
const posts = (await getCollection('insights')).map((post) => ({ params: { slug: post.id }, props: { post } }));
const mentions = (await getCollection('mentions')).map((mention) => ({ params: { slug: mention.id }, props: { mention } }));
const taken = new Set(posts.map((p) => p.params.slug));
const clash = mentions.find((m) => taken.has(m.params.slug));
if (clash) throw new Error(`Insights slug used by an essay and a mention: ${clash.params.slug}`);
return [...posts, ...mentions];
}
const { post } = Astro.props;
const { post, mention } = Astro.props;
---
<Post lang="en" post={post} />
{post ? <Post lang="en" post={post} /> : <Mention lang="en" mention={mention!} />}
+6
View File
@@ -17,6 +17,12 @@ const ui = t('en');
<h2>Contact</h2>
<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>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>
<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>
<h2>Right to information, correction, blocking, or deletion</h2>
<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>
+185
View File
@@ -0,0 +1,185 @@
/* Chladni figures for the research cards: the nodal lines of a vibrating plate, traced as the zero
contour of a wave field sampled on a grid (marching squares). Two fields:
plate z = a·sin(πn·x)·sin(πm·y) + b·sin(πm·x)·sin(πn·y), optionally rotated, which gives
rounded lattices, lenses and, turned, diamond and diagonal weaves;
sources waves from `sources` points on a circle around the centre, interfering:
z = Σ sin(2π·frequency·(phase − d / elasticity)), which gives rings, trefoils, petals.
Hovering a card tweens the numbers to its hover set, so the figure reflows into a related one
rather than sliding. Cheap by construction: a card draws once on load and on resize, and only
redraws during the 600 ms tween after a hover change. Nothing runs while the pointer is still. */
interface Common {
/** Centre of the sampled window. */
origin: [number, number];
/** Height of the sampled window; its width follows the canvas aspect. */
length: number;
/** Grid points per side. */
quality: number;
/** Mirror the lower-right quadrant into all four, around the canvas centre. */
quadrant?: boolean;
}
export interface PlateShape { a: number; b: number; n: number; m: number; rotation?: number }
export interface SourcesShape { frequency: number; phase: number; elasticity: number; radius: number; spin?: number }
export type ChladniParams =
| (Common & { mode: 'plate'; hover: Partial<PlateShape> } & PlateShape)
| (Common & { mode: 'sources'; sources: number; hover: Partial<SourcesShape> } & SourcesShape);
type Point = [number, number];
type Shape = Record<string, number>;
const DURATION = 600;
const PLATE_KEYS = ['a', 'b', 'n', 'm', 'rotation'] as const;
const SOURCE_KEYS = ['frequency', 'phase', 'elasticity', 'radius', 'spin'] as const;
/* cubic-bezier(0.2, 0, 0.3, 1): solved for x by Newton's method, then evaluated for y. */
function ease(t: number): number {
const x1 = 0.2, y1 = 0, x2 = 0.3, y2 = 1;
const bez = (u: number, p1: number, p2: number) => 3 * (1 - u) ** 2 * u * p1 + 3 * (1 - u) * u ** 2 * p2 + u ** 3;
const dbez = (u: number, p1: number, p2: number) => 3 * (1 - u) ** 2 * p1 + 6 * (1 - u) * u * (p2 - p1) + 3 * u ** 2 * (1 - p2);
let u = t;
for (let i = 0; i < 6; i++) {
const d = dbez(u, x1, x2);
if (Math.abs(d) < 1e-6) break;
u -= (bez(u, x1, x2) - t) / d;
}
return bez(Math.min(Math.max(u, 0), 1), y1, y2);
}
function fieldFor(p: ChladniParams, s: Shape): (x: number, y: number) => number {
if (p.mode === 'plate') {
const { a, b, n, m } = s;
const rot = s.rotation ?? 0;
const cos = Math.cos(rot), sin = Math.sin(rot);
const [ox, oy] = p.origin;
return (x, y) => {
// Rotate about the window centre, so turning the plate keeps the figure in frame.
const dx = x - ox, dy = y - oy;
const u = ox + dx * cos - dy * sin;
const v = oy + dx * sin + dy * cos;
return a * Math.sin(Math.PI * n * u) * Math.sin(Math.PI * m * v) + b * Math.sin(Math.PI * m * u) * Math.sin(Math.PI * n * v);
};
}
const pts: Point[] = Array.from({ length: p.sources }, (_, i) => {
const t = (i / p.sources) * 2 * Math.PI - Math.PI / 2 + (s.spin ?? 0);
return [p.origin[0] + s.radius * Math.cos(t), p.origin[1] + s.radius * Math.sin(t)];
});
const k = 2 * Math.PI * s.frequency;
const inv = 1 / s.elasticity;
return (x, y) => {
let z = 0;
for (const [sx, sy] of pts) z += Math.sin(k * (s.phase - inv * Math.hypot(x - sx, y - sy)));
return z;
};
}
function segments(p: ChladniParams, shape: Shape, w: number, h: number): Point[][] {
const q = p.quality;
const height = p.length;
const width = p.length * (w / h);
const x0 = p.origin[0] - width / 2;
const y0 = p.origin[1] - height / 2;
const sx = w / width;
const sy = h / height;
const start = p.quadrant ? Math.ceil(q / 2) - 1 : 0;
const f = fieldFor(p, shape);
const xs = new Float64Array(q);
const ys = new Float64Array(q);
for (let i = 0; i < q; i++) {
xs[i] = x0 + (i * width) / (q - 1);
ys[i] = y0 + (i * height) / (q - 1);
}
const z = new Float64Array(q * q);
for (let r = start; r < q; r++) for (let c = start; c < q; c++) z[r * q + c] = f(xs[c], ys[r]);
const lerp = (v0: number, v1: number, p0: number, p1: number) =>
Math.abs(v0 - v1) < 1e-10 ? (p0 + p1) / 2 : p0 + ((0 - v0) * (p1 - p0)) / (v1 - v0);
const out: Point[][] = [];
for (let r = start; r < q - 1; r++) {
for (let c = start; c < q - 1; c++) {
const tl = z[r * q + c], tr = z[r * q + c + 1], br = z[(r + 1) * q + c + 1], bl = z[(r + 1) * q + c];
const k = (tl > 0 ? 1 : 0) | (tr > 0 ? 2 : 0) | (br > 0 ? 4 : 0) | (bl > 0 ? 8 : 0);
if (k === 0 || k === 15) continue;
const xa = xs[c], xb = xs[c + 1], ya = ys[r], yb = ys[r + 1];
const top: Point = [(lerp(tl, tr, xa, xb) - x0) * sx, (ya - y0) * sy];
const right: Point = [(xb - x0) * sx, (lerp(tr, br, ya, yb) - y0) * sy];
const bottom: Point = [(lerp(bl, br, xa, xb) - x0) * sx, (yb - y0) * sy];
const left: Point = [(xa - x0) * sx, (lerp(tl, bl, ya, yb) - y0) * sy];
switch (k) {
case 1: case 14: out.push([left, top]); break;
case 2: case 13: out.push([top, right]); break;
case 3: case 12: out.push([left, right]); break;
case 4: case 11: out.push([right, bottom]); break;
case 6: case 9: out.push([top, bottom]); break;
case 7: case 8: out.push([left, bottom]); break;
case 5: out.push([left, top], [right, bottom]); break;
case 10: out.push([top, right], [left, bottom]); break;
}
}
}
return out;
}
export function mountChladni(canvas: HTMLCanvasElement, trigger: HTMLElement, params: ChladniParams, reduceMotion: boolean) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const keys: readonly string[] = params.mode === 'plate' ? PLATE_KEYS : SOURCE_KEYS;
const source = params as unknown as Shape;
// Every animatable key gets a number, 0 when the figure leaves it out (rotation, spin): a key
// that only the hover set names would otherwise tween from undefined and blank the figure.
const rest: Shape = {};
for (const k of keys) rest[k] = typeof source[k] === 'number' ? source[k] : 0;
const hover: Shape = { ...rest, ...(params.hover as Shape) };
let shape: Shape = { ...rest };
let frame = 0;
const draw = () => {
const dpr = window.devicePixelRatio || 1;
const w = Math.round(canvas.clientWidth * dpr);
const h = Math.round(canvas.clientHeight * dpr);
if (!w || !h) return;
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = getComputedStyle(canvas).color;
ctx.lineWidth = dpr;
ctx.lineCap = 'round';
ctx.beginPath();
const segs = segments(params, shape, w, h);
const mirrors: Point[] = params.quadrant ? [[1, 1], [-1, 1], [1, -1], [-1, -1]] : [[1, 1]];
for (const [fx, fy] of mirrors) {
for (const [p0, p1] of segs) {
ctx.moveTo(fx === 1 ? p0[0] : w - p0[0], fy === 1 ? p0[1] : h - p0[1]);
ctx.lineTo(fx === 1 ? p1[0] : w - p1[0], fy === 1 ? p1[1] : h - p1[1]);
}
}
ctx.stroke();
};
const tweenTo = (target: Shape) => {
cancelAnimationFrame(frame);
if (reduceMotion) { shape = { ...target }; draw(); return; }
const from = { ...shape };
const t0 = performance.now();
const step = (now: number) => {
const t = Math.min((now - t0) / DURATION, 1);
const e = ease(t);
const next: Shape = {};
for (const k of Object.keys(target)) next[k] = from[k] + (target[k] - from[k]) * e;
shape = next;
draw();
if (t < 1) frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
};
trigger.addEventListener('pointerenter', () => tweenTo(hover));
trigger.addEventListener('pointerleave', () => tweenTo(rest));
trigger.addEventListener('focus', () => tweenTo(hover));
trigger.addEventListener('blur', () => tweenTo(rest));
new ResizeObserver(draw).observe(canvas);
draw();
}
+196
View File
@@ -0,0 +1,196 @@
/* The home hero's noise field: a domain-warped noise gradient in the logo's blues on night,
with film grain, drifting slowly and bending towards the pointer.
Kept cheap on purpose: it renders at half the CSS resolution (a soft gradient loses nothing),
caps at 30 fps, and only runs while the hero is on screen and the tab is visible. The caller
decides whether to start it at all (reduced motion, no WebGL); the poster image underneath
is a frame of this same shader, so the hand-over is invisible. */
const VERTEX = `
attribute vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
`;
const FRAGMENT = `
precision mediump float;
uniform vec2 uRes;
uniform float uTime;
uniform vec2 uPointer;
// Logo palette, deepest to lightest, on the site's night.
const vec3 NIGHT = vec3(0.055, 0.063, 0.059);
const vec3 DEEP = vec3(0.157, 0.282, 0.533); // #284888
const vec3 MID = vec3(0.220, 0.408, 0.596); // #386898
const vec3 TEAL = vec3(0.282, 0.659, 0.722); // #48A8B8
const vec3 CYAN = vec3(0.345, 0.784, 0.847); // #58C8D8
// 2D simplex noise (Ashima Arts / Stefan Gustavson, MIT).
vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m = m * m; m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 3; i++) { v += a * snoise(p); p = p * 2.02 + 17.0; a *= 0.5; }
return v;
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec2 uv = gl_FragCoord.xy / uRes;
float aspect = uRes.x / uRes.y;
vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
vec2 m = (uPointer - 0.5) * vec2(aspect, 1.0);
float t = uTime * 0.035;
// The pointer bends the field towards itself, strongest close by, and lifts it a little.
vec2 d = m - p;
float pull = exp(-dot(d, d) * 2.4);
p += d * pull * 0.28;
// Low-frequency warping: broad, slow folds rather than texture.
vec2 q = vec2(fbm(p * 0.55 + vec2(0.0, t)), fbm(p * 0.55 + vec2(5.2, 1.3) - t));
float n = fbm(p * 0.8 + q * 1.2 + vec2(1.7, 9.2) + 0.5 * t);
// A diagonal gradient, dark lower left (where the headline sits) to light upper right,
// whose edges the noise folds.
vec2 s = p / vec2(aspect, 1.0) + 0.5;
float g = s.x * 0.55 + s.y * 0.75 - 0.38 + n * 0.38 + pull * 0.14;
vec3 col = mix(NIGHT, DEEP, smoothstep(0.04, 0.46, g));
col = mix(col, MID, smoothstep(0.32, 0.72, g));
vec3 high = mix(TEAL, CYAN, smoothstep(-0.25, 0.55, q.x));
col = mix(col, high, smoothstep(0.58, 1.02, g) * 0.85);
// Static film grain, so the drift reads as light moving under paper rather than TV noise.
col += (hash(gl_FragCoord.xy) - 0.5) * 0.06;
gl_FragColor = vec4(col, 1.0);
}
`;
const SCALE = 0.5;
const MAX_WIDTH = 1280;
const FRAME_MS = 1000 / 30;
const EASE = 0.06;
export function startNoiseField(canvas: HTMLCanvasElement, area: HTMLElement): (() => void) | undefined {
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, depth: false, powerPreference: 'low-power' });
if (!gl) return undefined;
const shader = (type: number, source: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, source);
gl.compileShader(s);
return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;
};
const vs = shader(gl.VERTEX_SHADER, VERTEX);
const fs = shader(gl.FRAGMENT_SHADER, FRAGMENT);
if (!vs || !fs) return undefined;
const program = gl.createProgram()!;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return undefined;
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uRes = gl.getUniformLocation(program, 'uRes');
const uTime = gl.getUniformLocation(program, 'uTime');
const uPointer = gl.getUniformLocation(program, 'uPointer');
// Pointer in hero coordinates, 0..1 with y up; eased every frame so it never jumps.
const target = { x: 0.62, y: 0.58 };
const pointer = { ...target };
const onPointer = (e: PointerEvent) => {
const r = area.getBoundingClientRect();
target.x = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1);
target.y = Math.min(Math.max(1 - (e.clientY - r.top) / r.height, 0), 1);
};
const resize = () => {
const w = Math.min(Math.round(canvas.clientWidth * SCALE), MAX_WIDTH);
const h = Math.round(w * (canvas.clientHeight / Math.max(canvas.clientWidth, 1)));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
};
const resizer = new ResizeObserver(resize);
resizer.observe(canvas);
resize();
let frame = 0;
let last = 0;
let elapsed = 0;
let visible = true;
const draw = (now: number) => {
frame = requestAnimationFrame(draw);
if (now - last < FRAME_MS) return;
elapsed += last ? Math.min(now - last, 100) : 0;
last = now;
pointer.x += (target.x - pointer.x) * EASE;
pointer.y += (target.y - pointer.y) * EASE;
gl.uniform2f(uRes, canvas.width, canvas.height);
gl.uniform1f(uTime, elapsed / 1000);
gl.uniform2f(uPointer, pointer.x, pointer.y);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (!canvas.hasAttribute('data-ready')) canvas.setAttribute('data-ready', '');
};
const run = () => {
if (frame || !visible || document.hidden) return;
last = 0;
frame = requestAnimationFrame(draw);
};
const pause = () => {
cancelAnimationFrame(frame);
frame = 0;
};
const onScreen = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) run(); else pause();
});
onScreen.observe(area);
const onVisibility = () => (document.hidden ? pause() : run());
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pointermove', onPointer, { passive: true });
const stop = () => {
pause();
onScreen.disconnect();
resizer.disconnect();
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pointermove', onPointer);
canvas.removeAttribute('data-ready');
};
// A lost context (GPU reset, too many tabs) hands back to the poster.
canvas.addEventListener('webglcontextlost', stop, { once: true });
run();
return stop;
}
+79
View File
@@ -18,6 +18,24 @@
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Self-hosted JetBrains Mono, variable, latin + latin-ext. Menu bar only. */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/jetbrains-mono-latin-wght-normal.woff2') format('woff2-variations');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/jetbrains-mono-latin-ext-wght-normal.woff2') format('woff2-variations');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Reset */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
@@ -92,6 +110,37 @@ p { overflow-wrap: break-word; }
font-weight: var(--weight-regular);
}
/* HUD frame: a 1px border with a small plus-shaped cross centred on each outer corner.
.hud-frame draws border and crosses; .hud-corners draws only the crosses, for an element whose
edges already sit on lines (the current menu item). Both read --line, --arm and --frame-color,
so a parent can retune them. Note: --line is redefined here as a width, not the colour token. */
.hud-frame,
.hud-corners {
--line: var(--hud-line-width);
--arm: var(--hud-arm);
--frame-color: var(--hud-line);
position: relative;
}
.hud-frame { border: var(--line) solid var(--frame-color); }
.hud-frame::after,
.hud-corners::after {
content: '';
position: absolute;
inset: calc(-1 * (var(--arm) + var(--line)));
pointer-events: none;
--len: calc(2 * var(--arm) + var(--line));
--g: linear-gradient(var(--frame-color), var(--frame-color)) no-repeat;
background:
var(--g) left 0 top var(--arm) / var(--len) var(--line),
var(--g) left var(--arm) top 0 / var(--line) var(--len),
var(--g) right 0 top var(--arm) / var(--len) var(--line),
var(--g) right var(--arm) top 0 / var(--line) var(--len),
var(--g) left 0 bottom var(--arm) / var(--len) var(--line),
var(--g) left var(--arm) bottom 0 / var(--line) var(--len),
var(--g) right 0 bottom var(--arm) / var(--len) var(--line),
var(--g) right var(--arm) bottom 0 / var(--line) var(--len);
}
/* Primitives */
.container {
width: 100%;
@@ -114,6 +163,8 @@ p { overflow-wrap: break-word; }
.measure { max-width: var(--measure); }
/* Night surface */
::selection { background: var(--selection); color: var(--selection-ink); }
.night {
background: var(--night);
color: var(--ink-on-night);
@@ -152,6 +203,34 @@ p { overflow-wrap: break-word; }
.night .button { background: var(--paper); color: var(--night); }
.night .button:hover { background: var(--paper-deep); }
/* Segmented choice: a row of radio options in the menu bar's language, hairline cells and mono
caps, the checked one filled with ink. Used for the research filters and the Insights filter.
Each option draws its own frame and overlaps its neighbours by one line, so a wrapped row
never leaves a rule running past its last option. */
.choice-group { border: 0; padding: 0; margin: 0; min-width: 0; display: grid; gap: var(--space-2); }
.choice-group__legend { padding: 0; color: var(--ink-muted); font-size: var(--step-small); }
.choices { display: flex; flex-wrap: wrap; padding: 0 var(--hairline) var(--hairline) 0; }
.choice { position: relative; display: flex; margin: 0 calc(-1 * var(--hairline)) calc(-1 * var(--hairline)) 0; }
.choice input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.choice > span {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border: var(--hairline) solid var(--line);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
text-transform: uppercase;
line-height: 1;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.choice:hover > span { background: color-mix(in srgb, var(--ink) var(--tint-hover), transparent); }
.choice input:checked + span { background: var(--ink); color: var(--paper); }
.choice input:focus-visible + span { outline: var(--focus-width) solid var(--ink); outline-offset: calc(-1 * var(--focus-width) - 2px); }
.choice__n { color: inherit; opacity: 0.6; }
/* Prose: long-form text */
.prose { max-width: var(--measure); }
.prose > * + * { margin-top: var(--space-5); }
+63
View File
@@ -10,6 +10,46 @@
--night: #0E100F;
--line: rgb(26 26 24 / 0.14);
/* Logo palette: only for the abstract fields (hero, research cards), never for text or UI. */
--logo-deep: #284888;
--logo-mid: #386898;
--logo-teal: #48A8B8;
--logo-cyan: #58C8D8;
/* Neighbours of the logo blues for the research card fields, so the cards vary without leaving
the brand: same coolness and depth, different hue. Fields only, never text or UI. */
--field-indigo: #3B4096;
--field-petrol: #1E5A63;
--field-sea: #2E7C72;
--field-steel: #4D6882;
--field-dusk: #55508F;
--field-cobalt: #2B56B0;
--field-ocean: #1F4E86;
--field-glacier: #7FB4CF;
--field-aqua: #3EAFB2;
--field-jade: #3B8C78;
--field-slate: #3A4862;
--field-plum: #66508E;
/* Warmer tones for the Insights card fields (cards without a picture), so the feed reads as
colour rather than paper. Fields only, never text or UI. Combined in src/components/Field.astro. */
--field-ember: #DE5B32;
--field-peach: #F2AE84;
--field-rose: #D4547C;
--field-violet: #6E4FCF;
--field-lilac: #B7A1EE;
--field-sun: #EFB443;
--field-mint: #86CFB0;
--field-sky: #6AAEE8;
/* Darkens the lower edge of a field so paper-coloured type on it holds 3:1 or better. */
--field-scrim: linear-gradient(to top, rgb(14 16 15 / 0.55), rgb(14 16 15 / 0) 65%);
/* 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. */
--selection: var(--logo-cyan);
--selection-ink: var(--ink-base);
/* Immutable copies for resetting inside a .night or overlay context */
--ink-base: #1A1A18;
--ink-muted-base: #57564F;
@@ -25,14 +65,21 @@
/* Type */
--font-sans: 'Inter Tight', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
/* Menu bar only: the tactical HUD labels. */
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--step-display: clamp(2.75rem, 1.2rem + 6.2vw, 7rem);
/* Home hero headline: about 10% below display, so the image keeps more of the frame. */
--step-hero: clamp(2.5rem, 1.1rem + 5.6vw, 6.25rem);
/* The coming-soon wordmark: one word sized to span the container, cut by the hero's lower edge. */
--step-giant: min(16.4vw, 14.25rem);
--step-h2: clamp(1.9rem, 1.1rem + 2.6vw, 3.25rem);
--step-h3: clamp(1.25rem, 1.05rem + 0.7vw, 1.6rem);
/* Card and footer titles: between h2 and h3, for headings that name rather than claim. */
--step-title: clamp(1.6rem, 1.2rem + 1.5vw, 2.5rem);
--step-body: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
--step-small: 0.875rem;
--step-micro: 0.75rem;
--weight-regular: 400;
--weight-medium: 500;
@@ -40,6 +87,7 @@
--track-display: -0.03em;
--track-h2: -0.02em;
--track-h3: -0.01em;
--track-caps: 0.08em;
--leading-display: 1.02;
--leading-h2: 1.08;
@@ -81,6 +129,21 @@
/* Chrome */
--header-height: 4.5rem;
/* HUD frame (menu bar): one line width and colour for every line, borders and crosses alike. */
--hud-line-width: 1px;
--hud-arm: 7px;
--hud-line: rgb(26 26 24 / 0.3);
--hud-line-on-night: rgb(233 229 223 / 0.3);
/* Frosted glass behind the bar. One blur, kept small so it stays cheap to composite. */
--glass: rgb(233 229 223 / 0.76);
--glass-on-night: rgb(14 16 15 / 0.56);
--glass-solid: rgb(233 229 223 / 0.96);
--glass-solid-on-night: rgb(14 16 15 / 0.94);
--glass-blur: 14px;
/* Tints mixed from the text colour, so they work on either tone. */
--tint-hover: 6%;
--tint-active: 9%;
/* Focus */
--focus-width: 2px;
--focus-offset: 3px;