Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5af2d78328 | ||
|
|
8385db6ebf | ||
|
|
018f27156c | ||
|
|
933de1c5a8 | ||
|
|
3865c45642 | ||
|
|
00edb9dfc2 | ||
|
|
2b509386eb | ||
|
|
c72d45740e | ||
|
|
c3f4651e71 | ||
|
|
c8740fb1d3 | ||
|
|
755d91d0f4 | ||
|
|
2458df3803 | ||
|
|
39bb0f480c | ||
|
|
49850fe485 |
No files matched your search
@@ -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.*
|
||||
@@ -9,3 +9,4 @@ ds-bundle/
|
||||
.design-sync/.cache/
|
||||
.design-sync/learnings/
|
||||
.design-sync/node_modules
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -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)
|
||||
|
After Width: | Height: | Size: 6.6 MiB |
|
After Width: | Height: | Size: 6.6 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 549 KiB After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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>
|
||||
@@ -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">
|
||||
<nav class="header__nav hud-frame" 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>
|
||||
))}
|
||||
{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>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,12 +17,13 @@ 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}
|
||||
{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]}
|
||||
@@ -30,15 +31,22 @@ const posts = (await getCollection('insights'))
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="insights__img img-doc"
|
||||
/>
|
||||
<p class="small muted">{post.data.category}</p>
|
||||
<h3 class="h3">{post.data.title}</h3>
|
||||
/> : <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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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} }`} />}
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
|
||||
/* Mentions: short references to posts elsewhere (LinkedIn first) that CipherCell took part in.
|
||||
Each gets a short page under /insights/<slug> with the post embedded on request. Query and
|
||||
fragment are dropped from the URL, so tracking and member IDs from a share link never reach
|
||||
the site. `embed` is derived from a LinkedIn post URL; set it by hand only if that fails. */
|
||||
const mentions = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/mentions' }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
summary: z.string(),
|
||||
url: z.url().transform((u) => {
|
||||
const clean = new URL(u);
|
||||
clean.search = '';
|
||||
clean.hash = '';
|
||||
return clean.toString();
|
||||
}),
|
||||
source: z.string(),
|
||||
platform: z.string().default('LinkedIn'),
|
||||
embed: z.url().optional(),
|
||||
embedHeight: z.number().int().positive().default(720),
|
||||
date: z.coerce.date(),
|
||||
lang: z.enum(['en', 'de']),
|
||||
}).transform((d) => {
|
||||
const activity = d.url.match(/activity[-:](\d+)/)?.[1];
|
||||
const embed = d.embed ?? (activity ? `https://www.linkedin.com/embed/feed/update/urn:li:activity:${activity}` : undefined);
|
||||
return { ...d, embed };
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { insights };
|
||||
export const collections = { insights, mentions };
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: 'CipherCell at the Berlin 6G Conference 2026.'
|
||||
summary: 'xG-Incubator announces Florian Frank, Marc Schlotzhauer and Felix Klement at StartUpConnect booth 20, with a demo that analyses software for telecommunications services statically, automatically and deterministically, air-gapped in the customer''s infrastructure. Felix Klement also joins the panel "AI for Trustworthiness".'
|
||||
url: 'https://www.linkedin.com/posts/xg-incubator_ciphercell-berlin-6g-conference-2026-activity-7499042305199366144-lS85'
|
||||
source: 'xG-Incubator'
|
||||
platform: 'LinkedIn'
|
||||
date: 2026-08-29
|
||||
lang: en
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: 'Component security testing at the xG-ALOE Plugfest 2026.'
|
||||
summary: 'xG-ALOE welcomes Felix Klement and Florian Frank of the University of Passau to the Plugfest in Berlin. They show a security testing platform for xG software: deterministic vulnerability detection with targeted AI support, with a focus on O-RAN.'
|
||||
url: 'https://www.linkedin.com/posts/xg-aloe_xgaloe-plugfest2026-openran-activity-7506238009315454976-HZPG'
|
||||
source: 'xG-ALOE'
|
||||
platform: 'LinkedIn'
|
||||
date: 2026-09-17
|
||||
lang: en
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: 'Two days on 5G and xG security in Nuremberg.'
|
||||
summary: 'At the xG-ALOE User Forum 2026, hosted by Fraunhofer IIS, Felix Klement and Heiner Grottendieck of the BSI led the tutorial "Securing the Evolution to xG" on the key security challenges of next-generation networks.'
|
||||
url: 'https://lnkd.in/p/eMDeMETT'
|
||||
source: 'CipherCell'
|
||||
platform: 'LinkedIn'
|
||||
date: 2026-06-26
|
||||
lang: en
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: 'Tutorial B2 at the xG-ALOE User Forum 2026.'
|
||||
summary: 'xG-ALOE announces the joint tutorial "Securing the Evolution to xG" by Felix Klement (University of Passau) and Heiner Grottendieck (BSI): current threat landscapes, security challenges in open telco architectures, and ways to strengthen network resilience. Fraunhofer IIS, Nuremberg, 24 and 25 June 2026.'
|
||||
url: 'https://www.linkedin.com/posts/xg-aloe_b2-5g-xg-activity-7459863696824262656-QRIN'
|
||||
source: 'xG-ALOE'
|
||||
platform: 'LinkedIn'
|
||||
date: 2026-05-12
|
||||
lang: en
|
||||
---
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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',
|
||||
|
||||
@@ -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,14 +29,28 @@ 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}>
|
||||
{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={post.data.cover}
|
||||
alt={post.data.coverAlt}
|
||||
src={item.entry.data.cover}
|
||||
alt={item.entry.data.coverAlt!}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[400, 640, 960]}
|
||||
@@ -36,15 +60,39 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
|
||||
class="post__img img-doc"
|
||||
/>
|
||||
</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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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)); } }
|
||||
|
||||
@@ -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}>
|
||||
{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>
|
||||
@@ -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!} />}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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!} />}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||