initial
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
@@ -0,0 +1,266 @@
|
||||
# CipherCell
|
||||
|
||||
**Company.** CipherCell, Passau (Innstr. 43). A security engineering company that grew out of Open RAN
|
||||
security research and works with the University of Passau, the xG-ALOE alliance and the xG-Incubator,
|
||||
with funding from the Federal Ministry of Research, Technology and Space. Eight people: two founders
|
||||
(Dr. Felix Klement, CEO; Dr. Florian Frank, CTO), engineers, business development, working students.
|
||||
|
||||
**What they sell.** Bespoke security for complex infrastructure in four stages: threat analysis and
|
||||
risk assessment (automated, in CI/CD), security engineering (zero trust, key management, secure APIs),
|
||||
security verification (independent review, hardening, testing) and monitoring and lifecycle.
|
||||
Sectors: Open RAN and private 5G, critical network infrastructure, cloud-native systems.
|
||||
|
||||
**Audience.** Operators of private 5G and Open RAN networks, integrators, industrial and clinical
|
||||
operators of critical networks, public research partners. Technical buyers who distrust marketing.
|
||||
|
||||
**Purpose of the site.** Establish CipherCell as an institution rather than a startup: state what
|
||||
they do in plain sentences, show the method, publish their thinking (Insights), and make contact easy.
|
||||
Bilingual EN/DE. Never invent proof points: every claim on the site traces to the current site,
|
||||
the founders' published writing or the funding badge.
|
||||
|
||||
**Implementation.** Astro, plain CSS custom properties, self-hosted Inter Tight. Copy lives in
|
||||
`src/i18n/ui.ts`, tokens in `src/styles/tokens.css`. See `README.md`.
|
||||
|
||||
---
|
||||
|
||||
# Design Brief: European Deep-Tech Minimalism
|
||||
|
||||
This file is the binding design foundation for this project. If a decision isn't covered here, decide in the spirit of this brief, not by the usual SaaS default.
|
||||
|
||||
---
|
||||
|
||||
## 1. Attitude
|
||||
|
||||
The site should look like an institution, not a startup. Composed, serious, understated. The impression comes from restraint: large surfaces, few elements, big typography, documentary photography.
|
||||
|
||||
Three rules that override everything else:
|
||||
|
||||
1. **Typography is the primary design tool.** Not color, not icons, not illustration.
|
||||
2. **White space is content.** If a section feels too empty, it's probably right.
|
||||
3. **No element without a job.** Borders, rules, labels and numbering may only appear when they carry information.
|
||||
|
||||
Reference points for the look: Arcos, Helsing, Anduril, Palantir, and the Swiss poster tradition they draw on. Not: Stripe, Linear clones, Vercel templates, standard YC landing pages.
|
||||
|
||||
---
|
||||
|
||||
## 2. Color
|
||||
|
||||
The foundation is a **warm paper tone**, never pure white. Contrast comes from exactly three states: paper, night, image.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--paper: #E9E5DF; /* base surface for the entire site */
|
||||
--paper-deep: #DFDAD1; /* offset surfaces, used sparingly */
|
||||
--ink: #1A1A18; /* body copy and headlines, warm-broken black */
|
||||
--ink-muted: #57564F; /* secondary text, metadata */
|
||||
--night: #0E100F; /* dark sections, image overlays */
|
||||
--line: rgb(26 26 24 / 0.14); /* hairlines */
|
||||
}
|
||||
```
|
||||
|
||||
Why warm black: on warm paper, pure black reads cold and foreign. `--ink` shares the warmth of the ground it sits on.
|
||||
|
||||
**Accent color: none by default.** Links and buttons work through underline, weight and surface, not color. If a signal tone is unavoidable (a live status dot, an alarm state in product UI), define exactly one and use it at most twice per page. Always build and look at the colorless version first.
|
||||
|
||||
Dark sections are allowed but rare: the hero and at most one other. The move from paper to night and back is a dramatic device and loses its effect through repetition.
|
||||
|
||||
---
|
||||
|
||||
## 3. Typography
|
||||
|
||||
One typeface family for the whole site: a **neo-grotesque** with tight counters and little personality in the details.
|
||||
|
||||
- Licensed (preferred if there's budget): Suisse Int'l, ABC Diatype, Söhne, Helvetica Now Display
|
||||
- Free: **Inter Tight** (default recommendation), Instrument Sans, Archivo, Geist
|
||||
|
||||
No serif. No second family for display. No monospace for labels.
|
||||
|
||||
Fonts are self-hosted `.woff2` only, with `font-display: swap`, variable font where available.
|
||||
|
||||
### Scale
|
||||
|
||||
Fluid scale using `clamp()`. The display size may be genuinely large — it's the one place where the page raises its voice.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--step-display: clamp(2.75rem, 1.2rem + 6.2vw, 7rem);
|
||||
--step-h2: clamp(1.9rem, 1.1rem + 2.6vw, 3.25rem);
|
||||
--step-h3: clamp(1.25rem, 1.05rem + 0.7vw, 1.6rem);
|
||||
--step-body: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
|
||||
--step-small: 0.875rem;
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
| Level | Weight | Tracking | Line height |
|
||||
|---|---|---|---|
|
||||
| Display | 400–500 | `-0.03em` | 1.02 |
|
||||
| H2 | 400–500 | `-0.02em` | 1.08 |
|
||||
| H3 | 500 | `-0.01em` | 1.2 |
|
||||
| Body | 400 | 0 | 1.55 |
|
||||
| Small / meta | 400–500 | 0 | 1.4 |
|
||||
|
||||
- The larger the size, the tighter the tracking. Never the reverse.
|
||||
- Body measure of 68 characters maximum (`max-width: 34em`).
|
||||
- Line breaks in headlines are set by hand (`<br>` or `text-wrap: balance`), not left to the browser. A headline is a composed object.
|
||||
- Everything flush left. Centered text only where there is a reason you can name.
|
||||
- **No all-caps label above headings.** If a section needs an eyebrow, set it in sentence case, in `--ink-muted`, at the same size as small text.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layout
|
||||
|
||||
```
|
||||
max-width: 1440px, centered
|
||||
Page margins: clamp(1.25rem, 5vw, 5rem)
|
||||
Grid: 12 columns, gap clamp(1rem, 2vw, 2rem)
|
||||
Section spacing: clamp(6rem, 12vw, 11rem) top and bottom
|
||||
```
|
||||
|
||||
Text blocks sit **asymmetrically**: headline across 7–8 columns, supporting body copy across 4–5, often offset against each other. Full width is reserved for images and the display headline.
|
||||
|
||||
Radii: `0`, at most `2px`. No rounded cards.
|
||||
Shadows: none. Separation comes from hairlines (`1px solid var(--line)`) or from a change of surface.
|
||||
|
||||
Home page wireframe:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Wordmark Navigation Contact │ ← slim, hairline below
|
||||
├──────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Full-bleed darkened image / video] │
|
||||
│ │
|
||||
│ Large display headline, │ ← white on image, lower left
|
||||
│ two lines. │
|
||||
│ │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Manifesto headline. Two paragraphs │ ← paper, generous air
|
||||
│ Second line. of body copy │
|
||||
│ [Link] │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ [Image] │ System / product headline │ ← 50/50, image bleeds off
|
||||
│ │ Explanation, link │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Sectors │
|
||||
│ ┌──────────┬──────────┬──────────┐ │ ← tabs or 3 columns,
|
||||
│ │ Image │ Image │ Image │ │ divided by hairlines
|
||||
│ │ Title │ Title │ Title │ │
|
||||
│ │ Sentence │ Sentence │ Sentence │ │
|
||||
│ └──────────┴──────────┴──────────┘ │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Proof row: three claims, type and rules only │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Quote, set large [Portrait] │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Insights: 3 posts, image + category + title │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Footer: mission sentence, 4 link columns │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Imagery
|
||||
|
||||
- Documentary, not staged. Aerial views, industrial sites, street scenes, people at work, reflections in glass.
|
||||
- Desaturated and darkened. Overlay: `linear-gradient(to top, rgb(14 16 15 / 0.75), rgb(14 16 15 / 0.2))`.
|
||||
- No stock-photo look, no smiling teams against white, no 3D renders, no generic icon sets.
|
||||
- Products appear as **technical line drawings**: single-color stroke in `--ink`, even stroke width, like a spec sheet. Inline SVG.
|
||||
- AVIF with WebP fallback, `<picture>`, `loading="lazy"` except in the hero, explicit `width`/`height` to prevent layout shift.
|
||||
- Every image gets descriptive alt text in the voice of the site ("An aerial view of pipework at an industrial plant"), not "Image 1".
|
||||
|
||||
---
|
||||
|
||||
## 6. Motion
|
||||
|
||||
One orchestrated moment on home page load: the hero image settles in from a slight scale, the headline fades in line by line. After that, stillness.
|
||||
|
||||
Banned, because generic:
|
||||
- Fade-and-slide-up on every section as it scrolls into view
|
||||
- Hover lift with shadow on every card
|
||||
- Parallax
|
||||
- Counting-up numbers
|
||||
|
||||
Allowed: state changes that answer a user action — navigation opening, a tab switching, an accordion expanding. Duration 180–320 ms, `cubic-bezier(0.2, 0, 0, 1)`.
|
||||
|
||||
`@media (prefers-reduced-motion: reduce)` turns all of it off. No exceptions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Language
|
||||
|
||||
Copy carries the style as much as the layout does. Rules:
|
||||
|
||||
- Short declarative sentences, with a full stop. "Safety is made, not given."
|
||||
- Contrast pairs as a structural device: "Verifiable, not asserted."
|
||||
- Sentence case, no exclamation marks, no rhetorical questions.
|
||||
- Concrete nouns over categories: "One signal, and a person answers." not "Innovative solutions for your security."
|
||||
- Banned: seamless, revolutionary, holistic, next-level, end-to-end, "At X, we believe that…", "In today's fast-moving world".
|
||||
- Buttons name the action: "Explore technology", not "Learn more". No "→" inside button text.
|
||||
- Headlines may state a claim, but never advertise.
|
||||
|
||||
---
|
||||
|
||||
## 8. Anti-patterns
|
||||
|
||||
These destroy the style immediately. Don't build them, not even "just in one place":
|
||||
|
||||
- Gradients as decoration, glassmorphism, blurred blobs
|
||||
- Identical rounded cards with soft shadows for every kind of content
|
||||
- Colored or filled icon sets, emoji
|
||||
- An all-caps eyebrow above every heading
|
||||
- Meta lines joined with middle dots ("Analysis · 5 min · 2026")
|
||||
- Monospace for small text and labels
|
||||
- A single word in the headline picked out in color
|
||||
- 01 / 02 / 03 numbering where the content isn't a sequence
|
||||
- A "Trusted by" logo bar with greyed-out logos
|
||||
- Cookie-banner styling that breaks with everything else
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical
|
||||
|
||||
Proposal, unless something else is already fixed:
|
||||
|
||||
- **Astro** as the framework, islands only where interactivity is genuinely needed (navigation, tabs). Static HTML as the default.
|
||||
- **Tailwind v4** with the tokens above in `@theme`, or plain CSS with custom properties. No UI library, no component kit.
|
||||
- Content collections for news and insights, Markdown as the source.
|
||||
- Bilingual EN/DE via `/` and `/de` routes, language switch in the footer.
|
||||
- Self-hosted fonts, no third-party font loading.
|
||||
- Target: Lighthouse ≥ 95 across all four categories, LCP under 2.0 s, CLS 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. How to work
|
||||
|
||||
Keep to this order:
|
||||
|
||||
1. **Tokens first.** Create `src/styles/tokens.css` with color, type scale and spacing scale, and commit it before any component exists. Hard-coded hex values or `px` spacing inside components are a defect and get reverted.
|
||||
2. **Primitives next.** Container, grid, section, prose. All spacing comes from the scale.
|
||||
3. **One section at a time**, each with real content. No lorem ipsum — write real copy following section 7, even if it will be replaced later.
|
||||
4. **Look at it and criticize it.** After each section, take screenshots at 1440 px and 390 px. Check against the anti-patterns in section 8 and state what you're changing.
|
||||
5. **Remove one thing.** Before a section counts as done: which element carries the least? Cut it.
|
||||
|
||||
### Definition of done, per section
|
||||
|
||||
- [ ] Tokens only, no magic numbers
|
||||
- [ ] Checked at 390 px, 768 px, 1440 px and 1920 px
|
||||
- [ ] Visible focus ring on every interactive element, full keyboard operation
|
||||
- [ ] Contrast at least 4.5:1 for body text, 3:1 for large text
|
||||
- [ ] Semantic HTML, one `<h1>` per page, no skipped heading levels
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Images with alt text, dimensions and a modern format
|
||||
- [ ] Checked against section 8
|
||||
|
||||
### When to ask
|
||||
|
||||
Ask rather than guess when: the company and its industry aren't established, no real photography exists, or a section needs content only the client has (certifications, quotes, figures). Never invent proof points, certifications or testimonials.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
Save this file as `CLAUDE.md` in the project root. Claude Code picks it up automatically as project context. Add a short section at the top covering the specific company, its audience and the purpose of the site — without that, the design stays generic.
|
||||
@@ -0,0 +1,77 @@
|
||||
# CipherCell homepage relaunch
|
||||
|
||||
Static site for www.ciphercell.de, built with Astro. Design foundation: `CLAUDE.md`
|
||||
(European deep-tech minimalism: warm paper, one neo-grotesque, hairlines, no accent colour).
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev # http://localhost:4321
|
||||
npm run build # static output in dist/
|
||||
npm run preview # serve dist/
|
||||
npm run check # Astro + TypeScript check
|
||||
```
|
||||
|
||||
## Where things live
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `src/styles/tokens.css` | Every colour, type step, spacing, radius and duration. Components use only these. |
|
||||
| `src/styles/base.css` | Font faces, reset, focus ring, primitives (`.container`, `.grid`, `.section`, `.prose`, `.link`, `.button`). |
|
||||
| `src/i18n/ui.ts` | All interface copy, English and German, one object per language with the same shape. |
|
||||
| `src/data/team.ts` | Team members, roles, portraits, links. |
|
||||
| `src/content/insights/*.md` | Insights posts (content collection). Front matter: title, date, lang, category, readingTime, author, cover. |
|
||||
| `src/components/home/*` | One file per home section, in page order: Hero, Manifesto, Method (with `MethodDiagram`), Stages (with `GridObject`), Sectors, Proof, Quote, Insights, Contact. |
|
||||
| `src/components/home/GridObject.astro` | The four wireframe objects on the stage cards (sphere, cube, plane, wave). Geometry is computed at build time; single stroke in `--ink`. |
|
||||
| `src/components/home/MethodDiagram.astro` | The four-stage loop, wide and tall compositions. Labels come from `ui.method.stages`. |
|
||||
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
|
||||
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
|
||||
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
|
||||
| `public/fonts/` | Self-hosted Inter Tight variable (latin, latin-ext). |
|
||||
|
||||
## Routes
|
||||
|
||||
`/`, `/team`, `/insights`, `/insights/<slug>`, `/imprint`, `/privacy`, `/404` and the same under `/de`.
|
||||
`hreflang` alternates and a sitemap are generated. The language switch sits in the footer.
|
||||
|
||||
## Things the host has to provide
|
||||
|
||||
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message` 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.
|
||||
- **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.
|
||||
|
||||
## Adding a post
|
||||
|
||||
Create `src/content/insights/<slug>.md` with the front matter used in `xg-aloe-user-forum.md`.
|
||||
Put the cover image in `src/assets/images/` and reference it with a relative path.
|
||||
The three newest posts appear on the home page automatically.
|
||||
|
||||
## Imagery
|
||||
|
||||
The mast at dusk (`mast-at-dusk.jpg`) is the one documentary photograph from the old site; it
|
||||
remains the cover of the first post. The five other scene images were generated with Magnific
|
||||
(Seedream 5 Pro) on 18 September 2026 in a desaturated documentary style, because no real
|
||||
photography existed:
|
||||
|
||||
| File | Used in | Magnific creation |
|
||||
|---|---|---|
|
||||
| `plant-at-dusk.jpg` | Hero | https://www.magnific.com/app/creation/DoQZmiipcl |
|
||||
| `engineer-glass.jpg` | Method section | https://www.magnific.com/app/creation/3zdScUIREY |
|
||||
| `sector-5g.jpg` | Sectors, tab 1 | https://www.magnific.com/app/creation/s7ZQP52l8e |
|
||||
| `sector-substation.jpg` | Sectors, tab 2 | https://www.magnific.com/app/creation/yi0r27vPW9 |
|
||||
| `sector-datacentre.jpg` | Sectors, tab 3 | https://www.magnific.com/app/creation/eIHAvetdqL |
|
||||
|
||||
They are placeholders in the sense of the brief: swap them for the company's own documentary
|
||||
photography when it exists, keeping the same file names and the `img-doc` treatment. The grid
|
||||
objects on the stage cards were prototyped as a Magnific sheet
|
||||
(https://www.magnific.com/app/creation/aFsD5crfSh) and then rebuilt as procedural SVG so the
|
||||
stroke matches the rest of the site.
|
||||
|
||||
## Interaction
|
||||
|
||||
Two islands of script: the mobile navigation toggle in `Header.astro` and the sectors tabs in
|
||||
`Sectors.astro` (ARIA tablist, arrow keys, Home and End). Without JavaScript every sector panel
|
||||
and its image is shown in full; the `html.js` class set in `Base.astro` switches to one at a time.
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://www.ciphercell.de',
|
||||
trailingSlash: 'never',
|
||||
integrations: [sitemap({ i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })],
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'de'],
|
||||
routing: { prefixDefaultLocale: false },
|
||||
},
|
||||
build: { inlineStylesheets: 'auto' },
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ciphercell-homepage",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter-tight": "^5.3.0",
|
||||
"astro": "^7.3.3",
|
||||
"sharp": "^0.35.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
"@astrojs/sitemap": "^3.7.4",
|
||||
"playwright": "^1.63.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#E9E5DF"/><text x="16" y="22" text-anchor="middle" font-family="Inter Tight, Inter, system-ui, sans-serif" font-weight="600" font-size="19" letter-spacing="-1" fill="#1A1A18">CC</text></svg>
|
||||
|
After Width: | Height: | Size: 286 B |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.ciphercell.de/sitemap-index.xml
|
||||
@@ -0,0 +1,18 @@
|
||||
import sharp from 'sharp';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const [,, dir, out, colsArg = '4', wArg = '360'] = process.argv;
|
||||
const cols = Number(colsArg), w = Number(wArg), h = Math.round(w * 0.66);
|
||||
const files = readdirSync(dir).filter(f => /\.(jpe?g|png|webp)$/i.test(f)).sort();
|
||||
const tiles = [];
|
||||
for (const [i, f] of files.entries()) {
|
||||
try {
|
||||
const buf = await sharp(join(dir, f)).resize(w, h, { fit: 'cover' }).toBuffer();
|
||||
const label = Buffer.from(`<svg width="${w}" height="24"><rect width="${w}" height="24" fill="black" opacity="0.6"/><text x="6" y="17" font-family="sans-serif" font-size="14" fill="white">${i}: ${f.slice(0, 40)}</text></svg>`);
|
||||
const tile = await sharp(buf).composite([{ input: label, top: h - 24, left: 0 }]).toBuffer();
|
||||
tiles.push({ input: tile, left: (i % cols) * w, top: Math.floor(i / cols) * h });
|
||||
} catch (e) { console.error('skip', f, e.message); }
|
||||
}
|
||||
const rows = Math.ceil(tiles.length / cols);
|
||||
await sharp({ create: { width: cols * w, height: rows * h, channels: 3, background: '#222' } }).composite(tiles).jpeg({ quality: 80 }).toFile(out);
|
||||
console.log('wrote', out, files.length, 'images');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, prefix, width = '1440', height = '900', max = '12'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
// trigger lazy loading by scrolling through
|
||||
const total = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
for (let y = 0; y < total; y += 600) { await page.evaluate((y) => window.scrollTo(0, y), y); await page.waitForTimeout(150); }
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(800);
|
||||
const h = Number(height);
|
||||
const n = Math.min(Number(max), Math.ceil(total / h));
|
||||
for (let i = 0; i < n; i++) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), i * h);
|
||||
await page.waitForTimeout(700);
|
||||
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png`, fullPage: false });
|
||||
}
|
||||
console.log('total height', total, 'shots', n);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,12 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, out, width = '1440', full = 'true'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 }, deviceScaleFactor: 1 });
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
const total = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
for (let y = 0; y < total; y += 500) { await page.evaluate((y) => window.scrollTo(0, y), y); await page.waitForTimeout(120); }
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: out, fullPage: full === 'true' });
|
||||
await browser.close();
|
||||
console.log('saved', out);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, out] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const tabs = page.locator('[role="tab"]');
|
||||
await tabs.nth(0).focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.waitForTimeout(500);
|
||||
const selected = await page.evaluate(() => Array.from(document.querySelectorAll('[role="tab"]')).map(t => t.getAttribute('aria-selected')));
|
||||
const activeImg = await page.evaluate(() => Array.from(document.querySelectorAll('[data-image]')).map(p => p.hasAttribute('data-active')));
|
||||
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
||||
console.log({ selected, activeImg, focused });
|
||||
await page.locator('#sectors').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
await browser.close();
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 413 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 549 KiB |
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import Wordmark from './Wordmark.astro';
|
||||
import { localePath, switchLang, t, type Lang } from '../i18n/ui';
|
||||
import fundingEn from '../assets/images/bmftr-en.jpg';
|
||||
import fundingDe from '../assets/images/bmftr-de.jpg';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const home = localePath(lang, '/');
|
||||
const year = new Date().getFullYear();
|
||||
const funding = lang === 'de' ? fundingDe : fundingEn;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: ui.footer.capabilities,
|
||||
links: ui.stages.items.map((item, i) => ({ href: `${home}#capability-${i + 1}`, label: item.title })),
|
||||
},
|
||||
{
|
||||
title: ui.footer.company,
|
||||
links: [
|
||||
{ href: localePath(lang, '/team'), label: ui.nav.team },
|
||||
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
|
||||
{ href: `${home}#sectors`, label: ui.nav.sectors },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: ui.footer.contact,
|
||||
links: [
|
||||
{ href: `${home}#contact`, label: ui.nav.contact },
|
||||
{ href: `mailto:${ui.contact.email}`, label: ui.footer.email },
|
||||
{ href: 'https://github.com/CipherCell-DEV', label: ui.footer.github, external: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: ui.footer.legal,
|
||||
links: [
|
||||
{ href: localePath(lang, '/imprint'), label: ui.footer.imprint },
|
||||
{ href: localePath(lang, '/privacy'), label: ui.footer.privacy },
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<hr class="rule" />
|
||||
<div class="grid footer__top">
|
||||
<div class="footer__brand">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<p class="footer__mission">{ui.footer.mission}</p>
|
||||
|
||||
<nav class="footer__nav" aria-label="Footer">
|
||||
<ul class="footer__lang" role="list" aria-label={ui.footer.language}>
|
||||
<li><a href={switchLang(Astro.url, 'en')} hreflang="en" lang="en" aria-current={lang === 'en' ? 'true' : undefined}>{ui.footer.en}</a></li>
|
||||
<li><a href={switchLang(Astro.url, 'de')} hreflang="de" lang="de" aria-current={lang === 'de' ? 'true' : undefined}>{ui.footer.de}</a></li>
|
||||
</ul>
|
||||
<div class="footer__cols">
|
||||
{columns.map((col) => (
|
||||
<div class="footer__col">
|
||||
<h2 class="footer__heading">{col.title}</h2>
|
||||
<ul role="list" class="footer__links">
|
||||
{col.links.map((l) => (
|
||||
<li><a href={l.href} rel={'external' in l && l.external ? 'noopener' : undefined}>{l.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<hr class="rule" />
|
||||
<div class="footer__bottom">
|
||||
<p class="small muted">{ui.footer.copyright(year)}</p>
|
||||
<Image src={funding} alt={ui.footer.fundingAlt} width={220} densities={[1, 2]} loading="lazy" class="footer__funding" />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer { padding-block: var(--space-6) var(--space-7); }
|
||||
.footer__top {
|
||||
padding-block: var(--space-8);
|
||||
row-gap: var(--space-8);
|
||||
}
|
||||
.footer__brand { grid-column: 1 / -1; }
|
||||
.footer__mission {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--step-title);
|
||||
line-height: var(--leading-title);
|
||||
letter-spacing: var(--track-h2);
|
||||
max-width: 16em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.footer__nav {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.footer__lang { display: flex; gap: var(--space-5); }
|
||||
.footer__lang a { text-decoration: none; color: var(--ink-muted); }
|
||||
.footer__lang a:hover { text-decoration: underline; }
|
||||
.footer__lang a[aria-current='true'] { color: var(--ink); font-weight: var(--weight-medium); }
|
||||
.footer__cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-7) var(--gap);
|
||||
}
|
||||
.footer__heading {
|
||||
font-size: var(--step-small);
|
||||
line-height: var(--leading-small);
|
||||
font-weight: var(--weight-regular);
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.footer__links li + li { margin-top: var(--space-2); }
|
||||
.footer__links a { text-decoration: none; }
|
||||
.footer__links a:hover { text-decoration: underline; }
|
||||
.footer__bottom {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
/* The ministry badge ships on a white field; multiply lets the paper show through without recolouring the mark. */
|
||||
.footer__funding { width: 11rem; height: auto; mix-blend-mode: multiply; }
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.footer__brand { grid-column: 1 / span 6; grid-row: 1; }
|
||||
.footer__mission { grid-column: 7 / span 6; grid-row: 1; }
|
||||
.footer__nav { grid-column: 1 / -1; grid-row: 2; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: var(--gap); align-items: end; }
|
||||
.footer__lang { grid-column: 1 / span 6; flex-direction: column; gap: var(--space-2); }
|
||||
.footer__cols { grid-column: 7 / span 6; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
import Wordmark from './Wordmark.astro';
|
||||
import { localePath, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const home = localePath(lang, '/');
|
||||
const items = [
|
||||
{ href: `${home}#capabilities`, label: ui.nav.capabilities },
|
||||
{ href: `${home}#sectors`, label: ui.nav.sectors },
|
||||
{ href: localePath(lang, '/insights'), label: ui.nav.insights },
|
||||
{ href: localePath(lang, '/team'), label: ui.nav.team },
|
||||
];
|
||||
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>
|
||||
|
||||
<nav class="header__nav" aria-label="Primary" id="primary-nav">
|
||||
<ul class="header__list" role="list">
|
||||
{items.map((item) => (
|
||||
<li><a href={item.href} aria-current={isCurrent(item.href) ? 'page' : undefined}>{item.label}</a></li>
|
||||
))}
|
||||
<li class="header__contact-mobile"><a href={contactHref}>{ui.nav.contact}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<a class="button button--small 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>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--paper);
|
||||
border-bottom: var(--hairline) solid var(--line);
|
||||
}
|
||||
.header__inner {
|
||||
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 {
|
||||
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__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 {
|
||||
display: none;
|
||||
font-weight: var(--weight-medium);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 47.99rem) {
|
||||
.header__contact { display: none; }
|
||||
.header__contact-mobile { display: block; }
|
||||
.header__toggle { display: inline-block; }
|
||||
.header__nav {
|
||||
position: absolute;
|
||||
inset: 100% 0 auto 0;
|
||||
display: none;
|
||||
padding: var(--space-5) var(--gutter) var(--space-7);
|
||||
background: var(--paper);
|
||||
border-bottom: var(--hairline) solid var(--line);
|
||||
}
|
||||
.header__nav[data-open] { display: block; }
|
||||
.header__list { flex-direction: column; gap: 0; }
|
||||
.header__list li + li { border-top: var(--hairline) solid var(--line); }
|
||||
.header__list a {
|
||||
display: block;
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step-h3);
|
||||
letter-spacing: var(--track-h3);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.header__list a[aria-current='page'] { font-weight: var(--weight-medium); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const header = document.querySelector<HTMLElement>('[data-header]');
|
||||
const toggle = header?.querySelector<HTMLButtonElement>('[data-nav-toggle]');
|
||||
const nav = header?.querySelector<HTMLElement>('#primary-nav');
|
||||
if (header && toggle && nav) {
|
||||
const open = toggle.querySelector<HTMLElement>('[data-label-open]')!;
|
||||
const close = toggle.querySelector<HTMLElement>('[data-label-close]')!;
|
||||
const set = (isOpen: boolean) => {
|
||||
toggle.setAttribute('aria-expanded', String(isOpen));
|
||||
if (isOpen) nav.setAttribute('data-open', ''); else nav.removeAttribute('data-open');
|
||||
open.hidden = isOpen;
|
||||
close.hidden = !isOpen;
|
||||
};
|
||||
toggle.addEventListener('click', () => set(toggle.getAttribute('aria-expanded') !== 'true'));
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') set(false); });
|
||||
nav.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('a')) set(false); });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
/** The wordmark is set in type. No symbol, no colour. */
|
||||
interface Props { class?: string }
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
<span class:list={['wordmark', className]}>CipherCell</span>
|
||||
|
||||
<style>
|
||||
.wordmark {
|
||||
display: inline-block;
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-h3);
|
||||
font-size: var(--step-h3);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import { t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const f = ui.contact.form;
|
||||
---
|
||||
<section class="section contact" id="contact" aria-labelledby="contact-title">
|
||||
<div class="container grid">
|
||||
<div class="contact__text">
|
||||
<h2 id="contact-title" class="h2">{ui.contact.title}</h2>
|
||||
<p class="contact__body">{ui.contact.body}</p>
|
||||
<address class="contact__address">
|
||||
<p>{ui.contact.address[0]}<br />{ui.contact.address[1]}</p>
|
||||
<p><a href={ui.contact.phoneHref}>{ui.contact.phone}</a><br /><a href={`mailto:${ui.contact.email}`}>{ui.contact.email}</a></p>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<form class="contact__form" action="/contact" method="post" lang={lang}>
|
||||
<p class="visually-hidden" aria-hidden="true">
|
||||
<label for="website">{f.honeypot}</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="first_name">{f.firstName} <span aria-hidden="true">*</span></label>
|
||||
<input id="first_name" name="first_name" type="text" autocomplete="given-name" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="last_name">{f.lastName} <span aria-hidden="true">*</span></label>
|
||||
<input id="last_name" name="last_name" type="text" autocomplete="family-name" required />
|
||||
</div>
|
||||
<div class="field field--full">
|
||||
<label for="email">{f.email} <span aria-hidden="true">*</span></label>
|
||||
<input id="email" name="email" type="email" autocomplete="email" required />
|
||||
</div>
|
||||
<div class="field field--full">
|
||||
<label for="message">{f.message} <span aria-hidden="true">*</span></label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
<div class="field--full contact__actions">
|
||||
<button class="button" type="submit">{f.submit}</button>
|
||||
<p class="small muted"><span aria-hidden="true">* </span>{f.required}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.contact { border-top: var(--hairline) solid var(--line); }
|
||||
.contact__text {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
align-content: start;
|
||||
}
|
||||
.contact__body { max-width: 28em; color: var(--ink-muted); }
|
||||
.contact__address { font-style: normal; display: grid; gap: var(--space-4); }
|
||||
.contact__address a { text-decoration: none; }
|
||||
.contact__address a:hover { text-decoration: underline; }
|
||||
|
||||
.contact__form {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6) var(--gap);
|
||||
align-content: start;
|
||||
}
|
||||
.field { display: grid; gap: var(--space-2); }
|
||||
.field--full { grid-column: 1 / -1; }
|
||||
.field label { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
|
||||
.field input, .field textarea {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: var(--hairline) solid var(--line);
|
||||
padding: var(--space-2) 0 var(--space-3);
|
||||
border-radius: 0;
|
||||
transition: border-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.field input:focus-visible, .field textarea:focus-visible {
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 8rem; }
|
||||
.contact__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.contact__text { grid-column: 1 / span 5; }
|
||||
.contact__form { grid-column: 7 / span 6; }
|
||||
}
|
||||
@media (max-width: 30rem) {
|
||||
.contact__form { grid-template-columns: 1fr; }
|
||||
.field { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
/**
|
||||
* Grid objects: wireframe solids drawn as technical line drawings.
|
||||
* Single stroke in currentColor, even width, no fills. The geometry is computed
|
||||
* at build time so the markup stays small and the stroke stays exact.
|
||||
* Kinds: sphere (a model seen from every side), cube (a built structure),
|
||||
* plane (one element lifted out for inspection), wave (a signal over time).
|
||||
*/
|
||||
interface Props { kind: 'sphere' | 'cube' | 'plane' | 'wave'; class?: string }
|
||||
const { kind, class: className } = Astro.props;
|
||||
|
||||
const S = 400;
|
||||
const f = (n: number) => Math.round(n * 10) / 10;
|
||||
const poly = (pts: [number, number][]) => pts.map(([x, y]) => `${f(x)},${f(y)}`).join(' ');
|
||||
|
||||
/* Isometric projection of a 3D point (x right, y up, z towards the viewer's left). */
|
||||
const ISO = { cx: S / 2, cy: S / 2, c30: Math.cos(Math.PI / 6), s30: Math.sin(Math.PI / 6) };
|
||||
const iso = (x: number, y: number, z: number, cy = ISO.cy): [number, number] =>
|
||||
[ISO.cx + (x - z) * ISO.c30, cy + (x + z) * ISO.s30 - y];
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (kind === 'sphere') {
|
||||
const R = 150, tilt = (22 * Math.PI) / 180, N = 48;
|
||||
const p3 = (lat: number, lon: number): [number, number] => {
|
||||
const x = R * Math.cos(lat) * Math.sin(lon);
|
||||
const y = R * Math.sin(lat);
|
||||
const z = R * Math.cos(lat) * Math.cos(lon);
|
||||
const y2 = y * Math.cos(tilt) - z * Math.sin(tilt);
|
||||
return [ISO.cx + x, ISO.cy - y2];
|
||||
};
|
||||
// Parallels every 20 degrees, meridians every 22.5 degrees (8 great circles).
|
||||
for (let d = -80; d <= 80; d += 20) {
|
||||
const lat = (d * Math.PI) / 180;
|
||||
const pts: [number, number][] = [];
|
||||
for (let i = 0; i <= N; i++) pts.push(p3(lat, (i / N) * 2 * Math.PI));
|
||||
lines.push(poly(pts));
|
||||
}
|
||||
for (let m = 0; m < 8; m++) {
|
||||
const lon = (m * Math.PI) / 8;
|
||||
const pts: [number, number][] = [];
|
||||
for (let i = 0; i <= N; i++) pts.push(p3((i / N) * 2 * Math.PI - Math.PI, lon));
|
||||
lines.push(poly(pts));
|
||||
}
|
||||
lines.push(`circle:${R}`);
|
||||
}
|
||||
|
||||
if (kind === 'cube') {
|
||||
const n = 4, u = 46, L = n * u, cy = ISO.cy + L * 0.5;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i * u;
|
||||
// top face (y = L)
|
||||
lines.push(poly([iso(t, L, 0, cy), iso(t, L, L, cy)]));
|
||||
lines.push(poly([iso(0, L, t, cy), iso(L, L, t, cy)]));
|
||||
// front-right face (z = 0): x by y
|
||||
lines.push(poly([iso(t, 0, 0, cy), iso(t, L, 0, cy)]));
|
||||
lines.push(poly([iso(0, t, 0, cy), iso(L, t, 0, cy)]));
|
||||
// front-left face (x = 0): z by y
|
||||
lines.push(poly([iso(0, 0, t, cy), iso(0, L, t, cy)]));
|
||||
lines.push(poly([iso(0, t, 0, cy), iso(0, t, L, cy)]));
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === 'plane') {
|
||||
const n = 6, u = 34, L = n * u, cy = ISO.cy + 20, h = 96, tx = 2, tz = 2;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i * u;
|
||||
lines.push(poly([iso(t, 0, 0, cy), iso(t, 0, L, cy)]));
|
||||
lines.push(poly([iso(0, 0, t, cy), iso(L, 0, t, cy)]));
|
||||
}
|
||||
const c: [number, number][] = [[tx, tz], [tx + 1, tz], [tx + 1, tz + 1], [tx, tz + 1]];
|
||||
const top = c.map(([x, z]) => iso(x * u, h, z * u, cy));
|
||||
lines.push(poly([...top, top[0]]));
|
||||
c.forEach(([x, z], i) => lines.push(poly([iso(x * u, 0, z * u, cy), top[i]])));
|
||||
}
|
||||
|
||||
if (kind === 'wave') {
|
||||
const nx = 12, nz = 8, u = 26, cy = ISO.cy + 10, A = 26;
|
||||
const yAt = (x: number, z: number) => A * Math.sin((x / nx) * Math.PI * 2 - 0.6) * Math.cos((z / nz) * Math.PI * 0.9);
|
||||
const P = (x: number, z: number) => iso((x - nx / 2) * u, yAt(x, z), (z - nz / 2) * u, cy);
|
||||
for (let z = 0; z <= nz; z++) {
|
||||
const pts: [number, number][] = [];
|
||||
for (let x = 0; x <= nx; x++) pts.push(P(x, z));
|
||||
lines.push(poly(pts));
|
||||
}
|
||||
for (let x = 0; x <= nx; x++) {
|
||||
const pts: [number, number][] = [];
|
||||
for (let z = 0; z <= nz; z++) pts.push(P(x, z));
|
||||
lines.push(poly(pts));
|
||||
}
|
||||
}
|
||||
---
|
||||
<svg class:list={['grid-object', className]} viewBox={`0 0 ${S} ${S}`} aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke">
|
||||
{lines.map((l) => l.startsWith('circle:')
|
||||
? <circle cx={S / 2} cy={S / 2} r={Number(l.slice(7))} />
|
||||
: <polyline points={l} />)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.grid-object {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import plant from '../../assets/images/plant-at-dusk.jpg';
|
||||
import { t, type Lang } from '../../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<section class="hero night" aria-labelledby="hero-title">
|
||||
<div class="hero__media">
|
||||
<Picture
|
||||
src={plant}
|
||||
alt={ui.hero.imageAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[640, 960, 1440, 1920, 2400, 3024]}
|
||||
sizes="100vw"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="hero__img img-cover img-doc"
|
||||
pictureAttributes={{ class: 'hero__picture' }}
|
||||
/>
|
||||
<div class="hero__overlay"></div>
|
||||
</div>
|
||||
|
||||
<div class="container hero__content">
|
||||
<h1 id="hero-title" class="display hero__title">
|
||||
{ui.hero.lines.map((line, i) => (
|
||||
<span class="hero__line" style={`--i: ${i}`}>{line}</span>
|
||||
))}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: clamp(30rem, calc(100svh - var(--header-height)), 56rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero__media { position: absolute; inset: 0; }
|
||||
:global(.hero__picture) { display: block; width: 100%; height: 100%; }
|
||||
.hero__img,
|
||||
:global(.hero__img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 60% 50%;
|
||||
transform-origin: 50% 60%;
|
||||
animation: hero-settle var(--dur-hero) var(--ease) both;
|
||||
}
|
||||
.hero__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.hero__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-block: var(--space-9);
|
||||
}
|
||||
.hero__title { max-width: 12em; }
|
||||
.hero__line {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
animation: hero-rise 700ms var(--ease) forwards;
|
||||
animation-delay: calc(240ms + var(--i) * 160ms);
|
||||
}
|
||||
|
||||
@keyframes hero-settle {
|
||||
from { transform: scale(1.045); }
|
||||
to { transform: none; }
|
||||
}
|
||||
@keyframes hero-rise {
|
||||
from { opacity: 0; transform: translateY(0.18em); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero__img, :global(.hero__img), .hero__line { animation: none; opacity: 1; transform: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
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);
|
||||
---
|
||||
<section class="section insights" aria-labelledby="insights-title">
|
||||
<div class="container">
|
||||
<div class="insights__head">
|
||||
<h2 id="insights-title" class="h2">{ui.insights.title}</h2>
|
||||
<a class="link" href={localePath(lang, '/insights')}>{ui.insights.all}</a>
|
||||
</div>
|
||||
|
||||
<ul class="insights__list" role="list">
|
||||
{posts.map((post) => (
|
||||
<li class="insights__item">
|
||||
<a class="insights__card" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
|
||||
<Picture
|
||||
src={post.data.cover}
|
||||
alt={post.data.coverAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[400, 640, 960]}
|
||||
sizes="(min-width: 64rem) 30vw, (min-width: 48rem) 45vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="insights__img img-doc"
|
||||
/>
|
||||
<p class="small muted">{post.data.category}</p>
|
||||
<h3 class="h3">{post.data.title}</h3>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li class="insights__item">
|
||||
<a class="insights__card insights__card--event" href={ui.insights.event.href} rel="noopener">
|
||||
<p class="insights__event-when">{ui.insights.event.when}</p>
|
||||
<p class="small muted">{ui.insights.event.category}</p>
|
||||
<h3 class="h3">{ui.insights.event.text}</h3>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.insights__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
}
|
||||
.insights__list {
|
||||
margin-top: var(--space-8);
|
||||
display: grid;
|
||||
gap: var(--space-8) var(--gap);
|
||||
}
|
||||
.insights__card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: var(--space-3);
|
||||
text-decoration: none;
|
||||
height: 100%;
|
||||
}
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.insights__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.insights__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { localePath, t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<section class="section manifesto" aria-labelledby="manifesto-title">
|
||||
<div class="container grid">
|
||||
<h2 id="manifesto-title" class="h2 manifesto__title">
|
||||
{ui.manifesto.lines.map((line) => <span class="line">{line}</span>)}
|
||||
</h2>
|
||||
<div class="manifesto__body">
|
||||
<p>{ui.manifesto.p1}</p>
|
||||
<p>{ui.manifesto.p2}</p>
|
||||
<a class="button" href={localePath(lang, '/team')}>{ui.manifesto.cta}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.line { display: block; }
|
||||
.manifesto { padding-block: calc(var(--section) * 1.15); }
|
||||
.manifesto__title { grid-column: 1 / -1; }
|
||||
.manifesto__body {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
justify-items: start;
|
||||
max-width: var(--measure);
|
||||
}
|
||||
.manifesto__body .button { margin-top: var(--space-2); }
|
||||
@media (min-width: 48rem) {
|
||||
.manifesto__title { grid-column: 1 / span 6; }
|
||||
.manifesto__body { grid-column: 7 / span 6; padding-top: var(--space-2); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import MethodDiagram from './MethodDiagram.astro';
|
||||
import engineer from '../../assets/images/engineer-glass.jpg';
|
||||
import { t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<section class="night method" id="capabilities" aria-labelledby="method-title">
|
||||
<Picture
|
||||
src={engineer}
|
||||
alt={ui.method.imageAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[640, 960, 1440, 1920, 2560]}
|
||||
sizes="100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="method__img img-cover img-doc"
|
||||
pictureAttributes={{ class: 'method__picture' }}
|
||||
/>
|
||||
|
||||
<div class="container grid method__intro">
|
||||
<h2 id="method-title" class="h2 method__title">
|
||||
{ui.method.lines.map((line, i) => <span class:list={['line', { 'line--muted': i > 0 }]}>{line}</span>)}
|
||||
</h2>
|
||||
<p class="method__body">{ui.method.body}</p>
|
||||
</div>
|
||||
|
||||
<div class="container method__figure">
|
||||
<MethodDiagram title={ui.method.diagramTitle} desc={ui.method.diagramDesc} stages={ui.method.stages} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.line { display: block; }
|
||||
.line--muted { color: var(--ink-muted); }
|
||||
:global(.method__picture) { display: block; width: 100%; }
|
||||
.method__img, :global(.method__img) {
|
||||
width: 100%;
|
||||
height: clamp(16rem, 36vw, 32rem);
|
||||
object-fit: cover;
|
||||
object-position: 50% 40%;
|
||||
}
|
||||
.method__intro {
|
||||
padding-block: var(--section) var(--space-9);
|
||||
align-items: start;
|
||||
}
|
||||
.method__title { grid-column: 1 / -1; }
|
||||
.method__body { grid-column: 1 / -1; max-width: 30em; }
|
||||
.method__figure { padding-bottom: var(--section); }
|
||||
.method__figure :global(.diagram) { max-width: 78rem; margin-inline: auto; }
|
||||
@media (min-width: 48rem) {
|
||||
.method__title { grid-column: 1 / span 6; }
|
||||
.method__body { grid-column: 7 / span 5; padding-top: var(--space-2); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
/**
|
||||
* The method as a closed loop of four stages, drawn as a spec-sheet diagram.
|
||||
* Main nodes sit on the corners of the loop with their labels inside;
|
||||
* each stage's instruments fan outwards. Wide and tall compositions of the same drawing.
|
||||
*/
|
||||
interface Stage { title: string; items: string[] }
|
||||
interface Props { title: string; desc: string; stages: Stage[] }
|
||||
const { title, desc, stages } = Astro.props;
|
||||
|
||||
const stroke = 1.25;
|
||||
const ring = 36;
|
||||
|
||||
/* Wide composition: 1400 x 560. Nodes on the corners of a loop. */
|
||||
const W = { w: 1400, h: 560, x1: 420, x2: 980, y1: 140, y2: 420, sat: 175, dy: 62 };
|
||||
const wideNodes = [
|
||||
{ x: W.x1, y: W.y1, side: -1, up: -1 }, // top-left: threat analysis
|
||||
{ x: W.x2, y: W.y1, side: 1, up: -1 }, // top-right: engineering
|
||||
{ x: W.x2, y: W.y2, side: 1, up: 1 }, // bottom-right: verification
|
||||
{ x: W.x1, y: W.y2, side: -1, up: 1 }, // bottom-left: monitoring
|
||||
];
|
||||
|
||||
/* Tall composition: 400 x 780. Nodes stacked on a spine, the loop closes on the left.
|
||||
The title sits right of the node, its instruments hang below the title. */
|
||||
const T = { w: 400, h: 780, x: 60, loopX: 20, y0: 60, step: 200, ring: 28, labelX: 40, satX: 44, satY: 36, dy: 28 };
|
||||
const tallNodes = stages.map((_, i) => ({ x: T.x, y: T.y0 + i * T.step }));
|
||||
|
||||
/* Direction tick: a small chevron at the midpoint of an edge, pointing along it. */
|
||||
const tick = (x: number, y: number, dx: number, dy: number, s = 7) => {
|
||||
const len = Math.hypot(dx, dy) || 1; const ux = dx / len, uy = dy / len;
|
||||
const px = -uy, py = ux;
|
||||
return `${x - ux * s + px * s},${y - uy * s + py * s} ${x},${y} ${x - ux * s - px * s},${y - uy * s - py * s}`;
|
||||
};
|
||||
---
|
||||
<svg class="diagram diagram--wide" viewBox={`0 0 ${W.w} ${W.h}`} role="img" aria-labelledby="method-w-title method-w-desc" xmlns="http://www.w3.org/2000/svg">
|
||||
<title id="method-w-title">{title}</title>
|
||||
<desc id="method-w-desc">{desc}</desc>
|
||||
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke">
|
||||
<rect x={W.x1} y={W.y1} width={W.x2 - W.x1} height={W.y2 - W.y1} />
|
||||
<polyline points={tick((W.x1 + W.x2) / 2, W.y1, 1, 0)} />
|
||||
<polyline points={tick(W.x2, (W.y1 + W.y2) / 2, 0, 1)} />
|
||||
<polyline points={tick((W.x1 + W.x2) / 2, W.y2, -1, 0)} />
|
||||
<polyline points={tick(W.x1, (W.y1 + W.y2) / 2, 0, -1)} />
|
||||
{wideNodes.map((n, i) => (
|
||||
<g>
|
||||
<circle cx={n.x} cy={n.y} r={ring} stroke-dasharray="2 5" />
|
||||
{stages[i].items.map((_, k) => {
|
||||
const sx = n.x + n.side * W.sat;
|
||||
const sy = n.y + (k - 1) * W.dy;
|
||||
return <line x1={n.x} y1={n.y} x2={sx} y2={sy} class="diagram__branch" />;
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
{wideNodes.map((n, i) => (
|
||||
<g>
|
||||
<circle cx={n.x} cy={n.y} r="5" />
|
||||
{stages[i].items.map((_, k) => <circle cx={n.x + n.side * W.sat} cy={n.y + (k - 1) * W.dy} r="3" class="diagram__dot" />)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
<g class="diagram__labels" font-size="17" font-weight="500">
|
||||
{wideNodes.map((n, i) => (
|
||||
<text x={n.x - n.side * (ring + 14)} y={n.y - n.up * 30 + 6} text-anchor={n.side < 0 ? 'start' : 'end'}>{stages[i].title}</text>
|
||||
))}
|
||||
</g>
|
||||
<g class="diagram__meta" font-size="15">
|
||||
{wideNodes.map((n, i) => stages[i].items.map((item, k) => (
|
||||
<text x={n.x + n.side * (W.sat + 14)} y={n.y + (k - 1) * W.dy + 5} text-anchor={n.side < 0 ? 'end' : 'start'}>{item}</text>
|
||||
)))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<svg class="diagram diagram--tall" viewBox={`0 0 ${T.w} ${T.h}`} role="img" aria-labelledby="method-t-title method-t-desc" xmlns="http://www.w3.org/2000/svg">
|
||||
<title id="method-t-title">{title}</title>
|
||||
<desc id="method-t-desc">{desc}</desc>
|
||||
<g fill="none" stroke="currentColor" stroke-width={stroke} stroke-linecap="square" vector-effect="non-scaling-stroke">
|
||||
<line x1={T.x} y1={tallNodes[0].y} x2={T.x} y2={tallNodes[3].y} />
|
||||
<polyline points={`${T.x},${tallNodes[3].y} ${T.loopX},${tallNodes[3].y} ${T.loopX},${tallNodes[0].y} ${T.x},${tallNodes[0].y}`} />
|
||||
{tallNodes.slice(0, 3).map((n) => <polyline points={tick(T.x, n.y + T.step / 2 + 20, 0, 1)} />)}
|
||||
<polyline points={tick(T.loopX, (tallNodes[0].y + tallNodes[3].y) / 2, 0, -1)} />
|
||||
{tallNodes.map((n, i) => (
|
||||
<g>
|
||||
<circle cx={n.x} cy={n.y} r={T.ring} stroke-dasharray="2 5" />
|
||||
{stages[i].items.map((_, k) => <line x1={n.x} y1={n.y} x2={n.x + T.satX} y2={n.y + T.satY + k * T.dy} class="diagram__branch" />)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
{tallNodes.map((n, i) => (
|
||||
<g>
|
||||
<circle cx={n.x} cy={n.y} r="5" />
|
||||
{stages[i].items.map((_, k) => <circle cx={n.x + T.satX} cy={n.y + T.satY + k * T.dy} r="3" class="diagram__dot" />)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
<g class="diagram__labels" font-size="16" font-weight="500">
|
||||
{tallNodes.map((n, i) => <text x={n.x + T.labelX} y={n.y + 6}>{stages[i].title}</text>)}
|
||||
</g>
|
||||
<g class="diagram__meta" font-size="14">
|
||||
{tallNodes.map((n, i) => stages[i].items.map((item, k) => (
|
||||
<text x={n.x + T.satX + 12} y={n.y + T.satY + k * T.dy + 5}>{item}</text>
|
||||
)))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.diagram {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.diagram__labels { fill: var(--ink); }
|
||||
.diagram__meta { fill: var(--ink-muted); }
|
||||
.diagram__branch { stroke: var(--line); }
|
||||
.diagram__dot { fill: var(--ink-muted); }
|
||||
.diagram--tall { display: none; }
|
||||
/* Below 64rem the wide drawing would scale its labels under 11px; the tall one takes over. */
|
||||
@media (max-width: 63.99rem) {
|
||||
.diagram--wide { display: none; }
|
||||
.diagram--tall { display: block; max-width: 26rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import { t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<section class="section proof" aria-labelledby="proof-title">
|
||||
<div class="container grid">
|
||||
<h2 id="proof-title" class="h2 proof__title">{ui.proof.title}</h2>
|
||||
<ul class="proof__list" role="list">
|
||||
{ui.proof.items.map((p) => (
|
||||
<li class="proof__item">
|
||||
<p class="proof__name">{p.name}</p>
|
||||
<p class="muted proof__body">{p.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.proof { border-top: var(--hairline) solid var(--line); }
|
||||
.proof__title { grid-column: 1 / -1; }
|
||||
.proof__list { grid-column: 1 / -1; }
|
||||
.proof__item {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding-block: var(--space-5);
|
||||
border-top: var(--hairline) solid var(--line);
|
||||
}
|
||||
.proof__item:last-child { border-bottom: var(--hairline) solid var(--line); }
|
||||
.proof__name { font-weight: var(--weight-medium); }
|
||||
.proof__body { max-width: 30em; }
|
||||
@media (min-width: 48rem) {
|
||||
.proof__title { grid-column: 1 / span 6; }
|
||||
.proof__list { grid-column: 7 / span 6; }
|
||||
.proof__item { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); column-gap: var(--gap); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import felix from '../../assets/images/people/felix.png';
|
||||
import { localePath, t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const essay = localePath(lang, '/insights/xg-aloe-user-forum');
|
||||
---
|
||||
<section class="section--tight quote" aria-labelledby="quote-text">
|
||||
<div class="container">
|
||||
<figure class="quote__figure">
|
||||
<div class="quote__panel">
|
||||
<p class="eyebrow"><a href={essay} class="quote__essay">{ui.quote.eyebrow} “From Open Interfaces to Open Risks”</a></p>
|
||||
<blockquote>
|
||||
<p id="quote-text" class="h2 quote__text">{lang === 'de' ? `„${ui.quote.text}“` : `“${ui.quote.text}”`}</p>
|
||||
</blockquote>
|
||||
<figcaption class="quote__caption">
|
||||
<p><span class="quote__name">{ui.quote.name}</span><br /><span class="muted">{ui.quote.role}</span></p>
|
||||
</figcaption>
|
||||
</div>
|
||||
<div class="quote__portrait">
|
||||
<Picture
|
||||
src={felix}
|
||||
alt={ui.quote.portraitAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 640, 960, 1200]}
|
||||
sizes="(min-width: 64rem) 40vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="img-mono quote__img"
|
||||
pictureAttributes={{ class: 'quote__picture' }}
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.quote__figure {
|
||||
display: grid;
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
.quote__panel {
|
||||
display: grid;
|
||||
gap: var(--space-7);
|
||||
align-content: space-between;
|
||||
padding: var(--space-7) var(--space-6);
|
||||
}
|
||||
.quote__essay { text-decoration: none; }
|
||||
.quote__essay:hover { text-decoration: underline; }
|
||||
.quote__text { text-wrap: balance; max-width: 16em; }
|
||||
.quote__name { font-weight: var(--weight-medium); }
|
||||
:global(.quote__picture) { display: block; width: 100%; height: 100%; }
|
||||
.quote__img, :global(.quote__img) { width: 100%; height: 100%; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 20%; }
|
||||
@media (min-width: 64rem) {
|
||||
.quote__figure { grid-template-columns: 11fr 9fr; }
|
||||
.quote__panel { padding: var(--space-8) var(--space-8); }
|
||||
.quote__img, :global(.quote__img) { aspect-ratio: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import fiveG from '../../assets/images/sector-5g.jpg';
|
||||
import substation from '../../assets/images/sector-substation.jpg';
|
||||
import datacentre from '../../assets/images/sector-datacentre.jpg';
|
||||
import { t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const images = [fiveG, substation, datacentre];
|
||||
const items = ui.sectors.items.map((item, i) => ({ ...item, image: images[i], id: `sector-${i + 1}` }));
|
||||
---
|
||||
<section class="section sectors" id="sectors" aria-labelledby="sectors-title">
|
||||
<div class="container sectors__grid" data-sectors>
|
||||
<div class="sectors__text">
|
||||
<h2 id="sectors-title" class="h2">{ui.sectors.title}</h2>
|
||||
<div class="sectors__tabs" role="tablist" aria-orientation="vertical" aria-labelledby="sectors-title">
|
||||
{items.map((item, i) => (
|
||||
<div class="sectors__tab" data-tab>
|
||||
<h3 class="sectors__heading">
|
||||
<button
|
||||
class="sectors__button"
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${item.id}-tab`}
|
||||
aria-controls={`${item.id}-panel`}
|
||||
aria-selected={i === 0 ? 'true' : 'false'}
|
||||
tabindex={i === 0 ? undefined : -1}
|
||||
>{item.title}</button>
|
||||
</h3>
|
||||
<div class="sectors__panel" role="tabpanel" id={`${item.id}-panel`} aria-labelledby={`${item.id}-tab`} tabindex="0">
|
||||
<div class="sectors__panel-inner">
|
||||
<p class="muted">{item.body}</p>
|
||||
<Picture
|
||||
src={item.image}
|
||||
alt={item.imageAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 768, 1024, 1440]}
|
||||
sizes="(min-width: 64rem) 50vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="sectors__img img-doc"
|
||||
pictureAttributes={{ class: 'sectors__picture' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sectors__media" aria-hidden="true">
|
||||
{items.map((item, i) => (
|
||||
<Picture
|
||||
src={item.image}
|
||||
alt=""
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[480, 768, 1024, 1440]}
|
||||
sizes="(min-width: 64rem) 50vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="sectors__img img-doc"
|
||||
pictureAttributes={{ class: 'sectors__picture sectors__picture--stage', 'data-image': String(i), 'data-active': i === 0 ? '' : undefined }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.sectors__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
column-gap: var(--gap);
|
||||
row-gap: var(--space-7);
|
||||
}
|
||||
.sectors__text {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
.sectors__tabs { margin-top: auto; }
|
||||
.sectors__tab + .sectors__tab { margin-top: var(--space-5); }
|
||||
.sectors__heading { font-size: inherit; font-weight: inherit; }
|
||||
.sectors__button {
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-size: var(--step-h3);
|
||||
line-height: var(--leading-h3);
|
||||
letter-spacing: var(--track-h3);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--ink);
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.sectors__button[aria-selected='false'] { color: var(--ink-muted); }
|
||||
.sectors__button[aria-selected='false']:hover { color: var(--ink); }
|
||||
.sectors__panel-inner { display: grid; gap: var(--space-5); padding-top: var(--space-3); }
|
||||
.sectors__panel p { max-width: 28em; }
|
||||
:global(.sectors__picture) { display: block; width: 100%; }
|
||||
.sectors__img, :global(.sectors__img) { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
|
||||
/* The stage image on the right exists for wide screens. On narrow screens each panel carries its own image. */
|
||||
.sectors__media { display: none; }
|
||||
|
||||
/* With scripting, one panel at a time; without, everything is readable. */
|
||||
:global(html.js) .sectors__panel {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
overflow: hidden;
|
||||
transition: grid-template-rows var(--dur-slow) var(--ease);
|
||||
}
|
||||
:global(html.js) .sectors__panel-inner { min-height: 0; }
|
||||
:global(html.js) .sectors__panel[data-open] { grid-template-rows: 1fr; }
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.sectors__text { grid-column: 1 / span 5; }
|
||||
.sectors__media {
|
||||
grid-column: 7 / span 6;
|
||||
display: grid;
|
||||
}
|
||||
.sectors__media :global(.sectors__picture--stage) {
|
||||
grid-area: 1 / 1;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-slow) var(--ease);
|
||||
}
|
||||
.sectors__media :global(.sectors__picture--stage[data-active]) { opacity: 1; }
|
||||
/* Inside the panels the image is redundant on wide screens. */
|
||||
.sectors__panel :global(.sectors__picture) { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sectors__panel, .sectors__media :global(.sectors__picture--stage) { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const root = document.querySelector<HTMLElement>('[data-sectors]');
|
||||
if (root) {
|
||||
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
|
||||
const panels = tabs.map((t) => document.getElementById(t.getAttribute('aria-controls')!)!);
|
||||
const images = Array.from(root.querySelectorAll<HTMLElement>('[data-image]'));
|
||||
const select = (index: number, focus = false) => {
|
||||
tabs.forEach((tab, i) => {
|
||||
const on = i === index;
|
||||
tab.setAttribute('aria-selected', String(on));
|
||||
if (on) tab.removeAttribute('tabindex'); else tab.setAttribute('tabindex', '-1');
|
||||
if (on) panels[i].setAttribute('data-open', ''); else panels[i].removeAttribute('data-open');
|
||||
if (on) images[i]?.setAttribute('data-active', ''); else images[i]?.removeAttribute('data-active');
|
||||
});
|
||||
if (focus) tabs[index].focus();
|
||||
};
|
||||
tabs.forEach((tab, i) => {
|
||||
tab.addEventListener('click', () => select(i));
|
||||
tab.addEventListener('keydown', (e) => {
|
||||
const n = tabs.length;
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); select((i + 1) % n, true); }
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); select((i - 1 + n) % n, true); }
|
||||
if (e.key === 'Home') { e.preventDefault(); select(0, true); }
|
||||
if (e.key === 'End') { e.preventDefault(); select(n - 1, true); }
|
||||
});
|
||||
});
|
||||
select(0);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import GridObject from './GridObject.astro';
|
||||
import { t, type Lang } from '../../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
|
||||
---
|
||||
<section class="section--tight stages" aria-labelledby="stages-title">
|
||||
<div class="container">
|
||||
<h2 id="stages-title" class="eyebrow stages__eyebrow">{ui.stages.eyebrow}</h2>
|
||||
<ol class="stages__list" role="list">
|
||||
{ui.stages.items.map((item, i) => (
|
||||
<li class="stages__item" id={`capability-${i + 1}`}>
|
||||
<h3 class="stages__title">{item.title}</h3>
|
||||
<GridObject kind={objects[i]} class="stages__object" />
|
||||
<p class="muted stages__body">{item.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.stages__eyebrow { margin-bottom: var(--space-6); }
|
||||
.stages__list {
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
}
|
||||
.stages__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto 1fr;
|
||||
align-items: start;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-6) var(--space-6) var(--space-7);
|
||||
background: var(--paper-deep);
|
||||
scroll-margin-top: calc(var(--header-height) + var(--space-5));
|
||||
}
|
||||
.stages__title {
|
||||
font-size: var(--step-title);
|
||||
line-height: var(--leading-title);
|
||||
letter-spacing: var(--track-h2);
|
||||
font-weight: var(--weight-regular);
|
||||
/* Two lines reserved, so objects and bodies line up across the row. */
|
||||
min-height: calc(2 * var(--leading-title) * 1em);
|
||||
hyphens: auto;
|
||||
}
|
||||
.stages__item :global(.stages__object) {
|
||||
width: 100%;
|
||||
max-width: 16rem;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
}
|
||||
.stages__body { max-width: 24em; }
|
||||
@media (min-width: 48rem) {
|
||||
.stages__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.stages__list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.stages__item :global(.stages__object) { max-width: 13rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { z } from 'astro/zod';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const insights = defineCollection({
|
||||
loader: glob({ pattern: '**/*.md', base: './src/content/insights' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
description: z.string(),
|
||||
date: z.coerce.date(),
|
||||
lang: z.enum(['en', 'de']),
|
||||
category: z.string(),
|
||||
readingTime: z.number().int().positive(),
|
||||
author: z.string(),
|
||||
authorRole: z.string(),
|
||||
cover: image(),
|
||||
coverAlt: z.string(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { insights };
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 'From Open Interfaces to Open Risks: Securing the Path to xG'
|
||||
subtitle: 'Reflections from the xG-ALOE User Forum 2026.'
|
||||
description: 'Why standard-compliant is not the same as secure, why nobody regulates your private 5G network, and why security in open networks needs its seatbelt moment.'
|
||||
date: 2026-06-30
|
||||
lang: en
|
||||
category: 'Open RAN'
|
||||
readingTime: 10
|
||||
author: 'Dr. Felix Klement'
|
||||
authorRole: 'Founder and CEO'
|
||||
cover: '../../assets/images/mast-at-dusk.jpg'
|
||||
coverAlt: 'A telecommunications mast at dusk, its warning light lit against a dark sky.'
|
||||
tags: ['O-RAN', 'Security', '5G', 'xG-ALOE', 'Network']
|
||||
---
|
||||
|
||||
## A forum built around real deployments
|
||||
|
||||
On June 24–25, 2026, the open enterprise networks community gathered at Fraunhofer IIS in Nuremberg for the first **xG-ALOE User Forum**, organized by the Fraunhofer-Gesellschaft (Heinrich Hertz Institute HHI and the Institute for Integrated Circuits IIS). The forum brought together users, system integrators, technology providers, and research institutions to share experiences with open campus networks and to look ahead to the evolution from 5G towards xG enterprise networks.
|
||||
|
||||
The format was intentionally designed to be practice-oriented: parallel tutorial tracks and expert-led sessions spanning regulation, O-RAN architectures, testing, monitoring, positioning, service quality and security. The exhibition area kept the conversation flowing between talks, with live demonstrations and plenty of opportunity for the kind of open exchange that only happens when vendors, researchers, operators, and end users share the same room.
|
||||
|
||||
## The security slot: a joint session with the BSI
|
||||
|
||||
Under the title "Securing the Evolution to xG: Insights into 5G and Future Telco Network Security," Heiner Grottendieck (Head of Division SZ31 at the Federal Office for Information Security, BSI) and I, Felix Klement, combined a national-regulatory perspective with academic and applied research.
|
||||
|
||||
In the first part of the session, the BSI provided an insight into its 5G/6G Competence Centre and the TEMIS security laboratory in Freital. There, they have a carrier-grade test environment in which end-to-end networks from Ericsson and Nokia, alongside non-public Open RAN-based networks from Airpuls and CampusGenius, are operated side by side. The talk then turned to the heart of the matter: why the security of private 5G networks is actually important.
|
||||
|
||||
Our part of the session, entitled "From Open Interfaces to Open Risks", focused on architectural aspects: how is the transition to open, decentralized networks changing the security landscape; what specific vulnerabilities is our research uncovering; and how operators can realistically assess and strengthen their security posture.
|
||||
|
||||
## People need pressure to change, and security is exactly that kind of change
|
||||
|
||||
One of the most honest moments of the forum came from the user side. In the panel on day 2, moderated by colleagues from brown-iposs, one conclusion stood out for its striking simplicity: most users just want "a better Wi-Fi." At a reasonable price, of course.
|
||||
|
||||
That expectation is the whole challenge in a single sentence. Security adds complexity. Security adds cost. It rarely shows up in a glossy demo, and it never makes the dashboard look greener. When the buyer's mental model is "Wi-Fi that just works", security is the first thing quietly traded away, and vendors, competing on price and simplicity, are happy to oblige by shipping protective options switched off by default.
|
||||
|
||||
So why insist on it anyway? A few concrete points from the session make the case:
|
||||
|
||||
- **Standard-compliant is not the same as secure.** 3GPP provides strong building blocks but many are optional, and vendors frequently ship them disabled. Disable user-plane integrity on a factory network, for instance, and an attacker who reaches the user plane can manipulate traffic and inject commands into machines while every dashboard stays "green."
|
||||
- **The breach is often operational, not protocol-level.** The way in is usually an unpatched VPN, a default credential, or a "trusted" LAN that extends core trust to the entire IT infrastructure of a factory or hospital, rather than a sophisticated attack on 5G signalling.
|
||||
- **Nobody is regulating your private network.** The BSI/BNetzA security catalog is mandatory only for public networks. Private networks fall outside it and are de facto unregulated. As the BSI put it bluntly: if you run a private 5G network, you are the operator, and the responsibility is yours.
|
||||
- **The consequences are physical.** Production stoppages, property damage, personal injury, and theft of sensitive data are not abstractions when a private 5G core is wired into industrial or clinical systems.
|
||||
|
||||
The encouraging news is that operators don't have to start from scratch. The BSI/BNetzA security catalog, which was created for public networks, is just as suitable as a ready-made blueprint for a private network and can be extended to secure the open interfaces and cross-vendor supply chain associated with O-RAN. And this is precisely where the pressure for change is becoming more urgent: it is not slowing down on the path to xG, it is intensifying. Every step toward more open, more disaggregated, and more AI-driven networks brings additional interfaces, vendors, and control loops, and thus a larger attack surface that must be secured. There are effective tools available for this, but they must be used. The security decisions an operator makes today form the foundation upon which the next generation will be built. Properly securing networks today is not a detour on the path to xG. It is the prerequisite for staying on that path.
|
||||
|
||||
## Conclusion: security needs its seatbelt moment
|
||||
|
||||
If there is one idea I want readers to take from this forum, it is that security is a remarkably versatile topic, and not only in the technical sense. It is shaped at least as much by external factors: economics, regulation, vendor incentives, and, above all, human attitudes. At its core, security is partly a societal problem. Before any security control matrix helps, people have to genuinely believe that security is needed.
|
||||
|
||||
The seatbelt is the analogy we keep returning to. Five decades ago, almost nobody thought seatbelts were necessary; they felt like an inconvenience that solved a problem most drivers were sure they would never have. Today, buckling up is as automatic as putting on your socks in the morning, and driving without a seatbelt feels deeply wrong. Security in private networks needs exactly that transition: from a half-heartedly accepted add-on to something so normal and so obviously desirable that operating without it would feel just as unthinkable as driving without a seatbelt.
|
||||
|
||||
It is worth being honest about the second half of the story: security is an iterative process that will never be fully "solved." The architecture of our systems keeps changing. The threat landscape keeps changing. Attacker capabilities keep changing. Even the scope of what we mean by "security" keeps expanding. Yesterday it was signalling, today it is AI models and supply chains. Security has to evolve continuously, in line with everything it protects.
|
||||
|
||||
That is precisely the work we are committed to at CipherCell, and it is why alliances like xG-ALOE matter: they are where the ecosystem builds the shared understanding and the trust that the path to xG will depend on.
|
||||
|
||||
Interested in learning more about xG-ALOE? Visit [xg-aloe.de](https://xg-aloe.de). To talk with us about assessing and strengthening the security posture of your private 5G or O-RAN deployment, [get in touch with the CipherCell team](/#contact).
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import felix from '../assets/images/people/felix.png';
|
||||
import florian from '../assets/images/people/florian.png';
|
||||
import alexander from '../assets/images/people/alexander.png';
|
||||
import erik from '../assets/images/people/erik.png';
|
||||
import marc from '../assets/images/people/marc.png';
|
||||
import philipp from '../assets/images/people/philipp.png';
|
||||
import karin from '../assets/images/people/karin.png';
|
||||
import valentin from '../assets/images/people/valentin.png';
|
||||
|
||||
export type RoleKey = 'ceo' | 'cto' | 'engineer' | 'bizdev' | 'studentF' | 'studentM';
|
||||
|
||||
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' },
|
||||
];
|
||||
@@ -0,0 +1,323 @@
|
||||
export type Lang = 'en' | 'de';
|
||||
export const langs: Lang[] = ['en', 'de'];
|
||||
export const defaultLang: Lang = 'en';
|
||||
|
||||
export function langFromUrl(url: URL): Lang {
|
||||
const [, first] = url.pathname.split('/');
|
||||
return first === 'de' ? 'de' : 'en';
|
||||
}
|
||||
|
||||
/** Prefix a site-relative path for a language. `path` starts with "/". */
|
||||
export function localePath(lang: Lang, path: string): string {
|
||||
if (lang === 'en') return path === '' ? '/' : path;
|
||||
return path === '/' ? '/de' : `/de${path}`;
|
||||
}
|
||||
|
||||
/** The same page in the other language. */
|
||||
export function switchLang(url: URL, to: Lang): string {
|
||||
const clean = url.pathname.replace(/^\/de(?=\/|$)/, '') || '/';
|
||||
return localePath(to, clean) + url.hash;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date, lang: Lang): string {
|
||||
return new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-GB', {
|
||||
day: 'numeric', month: 'long', year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
const en = {
|
||||
meta: {
|
||||
siteName: 'CipherCell',
|
||||
home: { title: 'CipherCell. Security engineering for complex infrastructure.', description: 'CipherCell is a security engineering company in Passau. Threat analysis, security engineering, verification and monitoring for private 5G, critical network infrastructure and cloud-native systems.' },
|
||||
team: { title: 'Team', description: 'The security researchers, engineers and architects behind CipherCell.' },
|
||||
insights: { title: 'Insights', description: 'Notes on the security of private 5G, critical networks and the systems that run them.' },
|
||||
imprint: { title: 'Legal notice', description: 'Legal notice for the CipherCell website.' },
|
||||
privacy: { title: 'Privacy', description: 'Privacy policy for the CipherCell website.' },
|
||||
},
|
||||
nav: {
|
||||
skip: 'Skip to content',
|
||||
capabilities: 'Capabilities',
|
||||
sectors: 'Sectors',
|
||||
insights: 'Insights',
|
||||
team: 'Team',
|
||||
contact: 'Contact',
|
||||
menu: 'Menu',
|
||||
close: 'Close',
|
||||
homeLabel: 'CipherCell, home',
|
||||
},
|
||||
hero: {
|
||||
lines: ['Security for the networks', '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.'],
|
||||
p1: 'Private 5G networks, industrial control systems and cloud platforms are assembled from the components of many vendors. Every interface between them is an attack surface, and off-the-shelf tools were not built for that.',
|
||||
p2: 'CipherCell is a security engineering company. We model real adversaries, build zero trust into the architecture rather than around it, and prove that the posture holds. From the first threat model to the running system.',
|
||||
cta: 'About CipherCell',
|
||||
},
|
||||
method: {
|
||||
lines: ['Four stages.', 'One method.'],
|
||||
body: 'Threat analysis, engineering, verification and monitoring, run as one continuous method rather than four separate projects. Modular, integrable and built for operational reality.',
|
||||
imageAlt: 'A security engineer seen through glass, the reflections of monitoring screens layered across her face.',
|
||||
diagramTitle: 'The CipherCell method',
|
||||
diagramDesc: 'A closed loop of four stages: threat analysis, engineering, verification and monitoring. Each stage carries its own instruments: threat model, attack paths and CI/CD integration; zero trust, key management and secure APIs; independent review, hardening and testing; intrusion detection, security logging and resilience checks. Monitoring feeds back into threat analysis.',
|
||||
stages: [
|
||||
{ title: 'Threat analysis', items: ['Threat model', 'Attack paths', 'CI/CD integration'] },
|
||||
{ title: 'Engineering', items: ['Zero trust', 'Key management', 'Secure APIs'] },
|
||||
{ title: 'Verification', items: ['Independent review', 'Hardening', 'Testing'] },
|
||||
{ title: 'Monitoring', items: ['Intrusion detection', 'Security logging', 'Resilience checks'] },
|
||||
],
|
||||
},
|
||||
stages: {
|
||||
eyebrow: 'What we do.',
|
||||
items: [
|
||||
{ title: 'Threat analysis', body: 'Automated, empirical analysis of real attack paths. It runs inside your CI/CD pipeline and delivers continuous assurance.' },
|
||||
{ title: 'Engineering', body: 'Zero-trust enforcement, key management and secure API design as modular, composable tools for complex deployments.' },
|
||||
{ title: 'Verification', body: 'Independent review of security measures, hardening checklists and application-level testing across all components.' },
|
||||
{ title: 'Monitoring', body: 'Intrusion detection and security logging, from the first deployment through ongoing resilience verification.' },
|
||||
],
|
||||
},
|
||||
sectors: {
|
||||
title: 'Who we work with',
|
||||
items: [
|
||||
{ title: 'Private 5G and campus networks', body: 'Disaggregated networks with open interfaces and a multi-vendor supply chain. Our origin, and still the hardest case.', imageAlt: 'A private 5G antenna and radio cabinet on the parapet of a factory roof, cranes in the distance.' },
|
||||
{ title: 'Critical network infrastructure', body: 'Networks where a breach stops production, damages equipment or endangers people.', imageAlt: 'An electrical substation seen straight from above: transformers, switchgear and a service road.' },
|
||||
{ title: 'Cloud-native systems', body: 'Containers, service meshes and CI/CD pipelines that carry infrastructure workloads. Security that runs as code, alongside yours.', imageAlt: 'A cold aisle between server racks in a data centre, a technician far in the distance.' },
|
||||
],
|
||||
},
|
||||
proof: {
|
||||
title: 'Where we come from.',
|
||||
items: [
|
||||
{ name: 'University of Passau', body: 'Our method grew out of academic security research. The university remains our research partner.' },
|
||||
{ name: 'xG-ALOE alliance and xG-Incubator', body: 'Partners in the alliance for open enterprise networks, from lab to deployment.' },
|
||||
{ name: 'Federal Ministry of Research, Technology and Space', body: 'Our work is publicly funded.' },
|
||||
],
|
||||
},
|
||||
quote: {
|
||||
eyebrow: 'From the essay',
|
||||
text: 'Standard-compliant is not the same as secure.',
|
||||
name: 'Dr. Felix Klement',
|
||||
role: 'Founder and CEO',
|
||||
link: 'Read the essay',
|
||||
portraitAlt: 'Portrait of Dr. Felix Klement, founder and CEO of CipherCell.',
|
||||
},
|
||||
insights: {
|
||||
title: 'Insights',
|
||||
all: 'All insights',
|
||||
intro: 'Notes on the security of private 5G, critical networks and the systems that run them.',
|
||||
readingTime: (min: number) => `${min} min read`,
|
||||
event: { category: 'Event', when: 'September 2026', text: 'Meet us at the xG‑ALOE Plugfest 2026.', link: 'Event details', href: 'https://xg-aloe.de/xg-aloe-plugfest-2026/' },
|
||||
back: 'All insights',
|
||||
by: 'By',
|
||||
inEnglish: 'This essay is available in English.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Talk to us.',
|
||||
body: 'Complex infrastructure, a tight compliance deadline, or a problem no one has solved yet. We respond within 24 hours.',
|
||||
address: ['Innstr. 43', '94032 Passau, Germany'],
|
||||
phone: '+49 (0)851 / 509-3218',
|
||||
phoneHref: 'tel:+498515093218',
|
||||
email: 'office@ciphercell.de',
|
||||
form: {
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email',
|
||||
message: 'Message',
|
||||
required: 'Required.',
|
||||
honeypot: 'Leave this field empty',
|
||||
submit: 'Send message',
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
mission: 'Security engineering for infrastructure where failure has consequences.',
|
||||
company: 'Company',
|
||||
capabilities: 'Capabilities',
|
||||
contact: 'Contact',
|
||||
legal: 'Legal',
|
||||
language: 'Language',
|
||||
imprint: 'Legal notice',
|
||||
privacy: 'Privacy',
|
||||
github: 'GitHub',
|
||||
email: 'Email',
|
||||
fundingAlt: 'With funding from the Federal Ministry of Research, Technology and Space.',
|
||||
copyright: (year: number) => `© ${year} CipherCell`,
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
},
|
||||
team: {
|
||||
title: ['Researchers,', 'engineers,', 'architects.'],
|
||||
intro: 'The people who go deep on the problems that matter, from 5G and private networks to hardware, software and cloud-native systems.',
|
||||
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',
|
||||
engineer: 'Software engineer',
|
||||
bizdev: 'Business development',
|
||||
studentF: 'Working student, development',
|
||||
studentM: 'Working student, development',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
body: 'The address you followed leads nowhere. The pages that exist are listed in the footer.',
|
||||
link: 'Go to the start page',
|
||||
},
|
||||
legal: {
|
||||
imprintEyebrow: 'Information pursuant to § 5 TMG',
|
||||
responsible: 'Responsible',
|
||||
mailing: 'Mailing address',
|
||||
contact: 'Contact',
|
||||
asOf: 'As of 14 June 2025',
|
||||
},
|
||||
};
|
||||
|
||||
const de: typeof en = {
|
||||
meta: {
|
||||
siteName: 'CipherCell',
|
||||
home: { title: 'CipherCell. Security Engineering für komplexe Infrastruktur.', description: 'CipherCell ist ein Security-Engineering-Unternehmen in Passau. Bedrohungsanalyse, Security Engineering, Verifikation und Monitoring für private 5G-Netze, kritische Netzinfrastruktur und Cloud-native Systeme.' },
|
||||
team: { title: 'Team', description: 'Die Sicherheitsforschenden, Ingenieurinnen und Architekten hinter CipherCell.' },
|
||||
insights: { title: 'Einblicke', description: 'Notizen zur Sicherheit privater 5G-Netze, kritischer Netze und der Systeme, die sie betreiben.' },
|
||||
imprint: { title: 'Impressum', description: 'Impressum der CipherCell-Website.' },
|
||||
privacy: { title: 'Datenschutz', description: 'Datenschutzerklärung der CipherCell-Website.' },
|
||||
},
|
||||
nav: {
|
||||
skip: 'Zum Inhalt springen',
|
||||
capabilities: 'Leistungen',
|
||||
sectors: 'Einsatzfelder',
|
||||
insights: 'Einblicke',
|
||||
team: 'Team',
|
||||
contact: 'Kontakt',
|
||||
menu: 'Menü',
|
||||
close: 'Schließen',
|
||||
homeLabel: 'CipherCell, Startseite',
|
||||
},
|
||||
hero: {
|
||||
lines: ['Sicherheit für die Netze,', 'auf denen 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.'],
|
||||
p1: 'Private 5G-Netze, industrielle Steuerungssysteme und Cloud-Plattformen werden aus Komponenten vieler Hersteller zusammengesetzt. Jede Schnittstelle dazwischen ist eine Angriffsfläche, und Standardwerkzeuge sind dafür nicht gebaut.',
|
||||
p2: 'CipherCell ist ein Security-Engineering-Unternehmen. Wir modellieren reale Angreifer, verankern Zero Trust in der Architektur statt daneben und weisen nach, dass sie standhält. Vom ersten Bedrohungsmodell bis zum laufenden System.',
|
||||
cta: 'Über CipherCell',
|
||||
},
|
||||
method: {
|
||||
lines: ['Vier Stufen.', 'Eine Methode.'],
|
||||
body: 'Bedrohungsanalyse, Engineering, Verifikation und Monitoring, als eine durchgehende Methode statt vier getrennter Projekte. Modular, integrierbar und gebaut für den operativen Alltag.',
|
||||
imageAlt: 'Eine Sicherheitsingenieurin hinter Glas, die Spiegelungen von Monitoring-Bildschirmen liegen über ihrem Gesicht.',
|
||||
diagramTitle: 'Die CipherCell-Methode',
|
||||
diagramDesc: 'Ein geschlossener Kreislauf aus vier Stufen: Bedrohungsanalyse, Engineering, Verifikation und Monitoring. Jede Stufe hat ihre Instrumente: Bedrohungsmodell, Angriffspfade und CI/CD-Integration; Zero Trust, Schlüsselmanagement und sichere APIs; unabhängige Prüfung, Härtung und Tests; Angriffserkennung, Security-Logging und Resilienzprüfung. Das Monitoring fließt zurück in die Bedrohungsanalyse.',
|
||||
stages: [
|
||||
{ title: 'Bedrohungsanalyse', items: ['Bedrohungsmodell', 'Angriffspfade', 'CI/CD-Integration'] },
|
||||
{ title: 'Engineering', items: ['Zero Trust', 'Schlüsselmanagement', 'Sichere APIs'] },
|
||||
{ title: 'Verifikation', items: ['Unabhängige Prüfung', 'Härtung', 'Tests'] },
|
||||
{ title: 'Monitoring', items: ['Angriffserkennung', 'Security-Logging', 'Resilienzprüfung'] },
|
||||
],
|
||||
},
|
||||
stages: {
|
||||
eyebrow: 'Was wir tun.',
|
||||
items: [
|
||||
{ title: 'Bedrohungs\u00ADanalyse', body: 'Automatisierte, empirische Analyse realer Angriffspfade. Sie läuft in Ihrer CI/CD-Pipeline und liefert kontinuierliche Absicherung.' },
|
||||
{ title: 'Engineering', body: 'Zero-Trust-Durchsetzung, Schlüsselmanagement und sicheres API-Design als modulare, kombinierbare Werkzeuge für komplexe Deployments.' },
|
||||
{ title: 'Verifikation', body: 'Unabhängige Prüfung von Sicherheitsmaßnahmen, Härtungs-Checklisten und anwendungsnahe Tests über alle Komponenten.' },
|
||||
{ title: 'Monitoring', body: 'Angriffserkennung und Security-Logging, vom ersten Deployment bis zur laufenden Resilienzprüfung.' },
|
||||
],
|
||||
},
|
||||
sectors: {
|
||||
title: 'Für wen wir arbeiten',
|
||||
items: [
|
||||
{ title: 'Private 5G- und Campusnetze', body: 'Disaggregierte Netze mit offenen Schnittstellen und einer Lieferkette aus vielen Herstellern. Unser Ursprung, und noch immer der schwerste Fall.', imageAlt: 'Eine private 5G-Antenne mit Funkschrank auf der Attika eines Fabrikdachs, Kräne im Hintergrund.' },
|
||||
{ title: 'Kritische Netzinfrastruktur', body: 'Netze, in denen ein Einbruch die Produktion stoppt, Anlagen beschädigt oder Menschen gefährdet.', imageAlt: 'Ein Umspannwerk senkrecht von oben: Transformatoren, Schaltanlagen und eine Zufahrt.' },
|
||||
{ title: 'Cloud-native Systeme', body: 'Container, Service Meshes und CI/CD-Pipelines, die Infrastruktur tragen. Sicherheit als Code, neben Ihrem.', imageAlt: 'Ein Kaltgang zwischen Serverschränken in einem Rechenzentrum, ein Techniker weit hinten.' },
|
||||
],
|
||||
},
|
||||
proof: {
|
||||
title: 'Woher wir kommen.',
|
||||
items: [
|
||||
{ name: 'Universität Passau', body: 'Unsere Methode ist aus akademischer Sicherheitsforschung entstanden. Die Universität bleibt unser Forschungspartner.' },
|
||||
{ name: 'xG-ALOE-Allianz und xG-Incubator', body: 'Partner in der Allianz für offene Unternehmensnetze, vom Labor bis zum Betrieb.' },
|
||||
{ name: 'Bundesministerium für Forschung, Technologie und Raumfahrt', body: 'Unsere Arbeit wird öffentlich gefördert.' },
|
||||
],
|
||||
},
|
||||
quote: {
|
||||
eyebrow: 'Aus dem Essay',
|
||||
text: 'Standardkonform ist nicht dasselbe wie sicher.',
|
||||
name: 'Dr. Felix Klement',
|
||||
role: 'Gründer und CEO',
|
||||
link: 'Essay lesen',
|
||||
portraitAlt: 'Porträt von Dr. Felix Klement, Gründer und CEO von CipherCell.',
|
||||
},
|
||||
insights: {
|
||||
title: 'Einblicke',
|
||||
all: 'Alle Einblicke',
|
||||
intro: 'Notizen zur Sicherheit privater 5G-Netze, kritischer Netze und der Systeme, die sie betreiben.',
|
||||
readingTime: (min: number) => `${min} Min. Lesezeit`,
|
||||
event: { category: 'Veranstaltung', when: 'September 2026', text: 'Treffen Sie uns beim xG‑ALOE Plugfest 2026.', link: 'Zur Veranstaltung', href: 'https://xg-aloe.de/xg-aloe-plugfest-2026/' },
|
||||
back: 'Alle Einblicke',
|
||||
by: 'Von',
|
||||
inEnglish: 'Dieser Essay liegt auf Englisch vor.',
|
||||
},
|
||||
contact: {
|
||||
title: 'Sprechen Sie mit uns.',
|
||||
body: 'Komplexe Infrastruktur, eine knappe Compliance-Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.',
|
||||
address: ['Innstr. 43', '94032 Passau, Deutschland'],
|
||||
phone: '+49 (0)851 / 509-3218',
|
||||
phoneHref: 'tel:+498515093218',
|
||||
email: 'office@ciphercell.de',
|
||||
form: {
|
||||
firstName: 'Vorname',
|
||||
lastName: 'Nachname',
|
||||
email: 'E-Mail',
|
||||
message: 'Nachricht',
|
||||
required: 'Pflichtfeld.',
|
||||
honeypot: 'Dieses Feld bitte leer lassen',
|
||||
submit: 'Nachricht senden',
|
||||
},
|
||||
},
|
||||
footer: {
|
||||
mission: 'Security Engineering für Infrastruktur, in der Ausfälle Folgen haben.',
|
||||
company: 'Unternehmen',
|
||||
capabilities: 'Leistungen',
|
||||
contact: 'Kontakt',
|
||||
legal: 'Rechtliches',
|
||||
language: 'Sprache',
|
||||
imprint: 'Impressum',
|
||||
privacy: 'Datenschutz',
|
||||
github: 'GitHub',
|
||||
email: 'E-Mail',
|
||||
fundingAlt: 'Gefördert vom Bundesministerium für Forschung, Technologie und Raumfahrt.',
|
||||
copyright: (year: number) => `© ${year} CipherCell`,
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
},
|
||||
team: {
|
||||
title: ['Forschende,', 'Ingenieure,', 'Architekten.'],
|
||||
intro: 'Die Menschen, die sich in die Probleme vertiefen, auf die es ankommt: von 5G und privaten Netzen bis zu Hardware, Software und Cloud-nativen Systemen.',
|
||||
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',
|
||||
engineer: 'Software Engineer',
|
||||
bizdev: 'Business Development',
|
||||
studentF: 'Werkstudentin, Entwicklung',
|
||||
studentM: 'Werkstudent, Entwicklung',
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Seite nicht gefunden',
|
||||
body: 'Die Adresse, der Sie gefolgt sind, führt ins Leere. Die vorhandenen Seiten stehen in der Fußzeile.',
|
||||
link: 'Zur Startseite',
|
||||
},
|
||||
legal: {
|
||||
imprintEyebrow: 'Angaben gemäß § 5 TMG',
|
||||
responsible: 'Verantwortlich',
|
||||
mailing: 'Postanschrift',
|
||||
contact: 'Kontakt',
|
||||
asOf: 'Stand: 14. Juni 2025',
|
||||
},
|
||||
};
|
||||
|
||||
export const ui: Record<Lang, typeof en> = { en, de };
|
||||
export function t(lang: Lang) { return ui[lang]; }
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import '../styles/base.css';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { langFromUrl, switchLang, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
lang?: Lang;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
const lang: Lang = Astro.props.lang ?? langFromUrl(Astro.url);
|
||||
const { title, description } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const fullTitle = title === ui.meta.home.title ? title : `${title}. ${ui.meta.siteName}`;
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site);
|
||||
const altEn = new URL(switchLang(Astro.url, 'en'), Astro.site);
|
||||
const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#E9E5DF" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="alternate" hreflang="en" href={altEn} />
|
||||
<link rel="alternate" hreflang="de" href={altDe} />
|
||||
<link rel="alternate" hreflang="x-default" href={altEn} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={ui.meta.siteName} />
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content={lang === 'de' ? 'de_DE' : 'en_GB'} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">{ui.nav.skip}</a>
|
||||
<Header lang={lang} />
|
||||
<main id="main" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer lang={lang} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import NotFound from './_NotFound.astro';
|
||||
---
|
||||
<NotFound lang="en" />
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import Hero from '../components/home/Hero.astro';
|
||||
import Manifesto from '../components/home/Manifesto.astro';
|
||||
import Method from '../components/home/Method.astro';
|
||||
import Stages from '../components/home/Stages.astro';
|
||||
import Sectors from '../components/home/Sectors.astro';
|
||||
import Proof from '../components/home/Proof.astro';
|
||||
import Quote from '../components/home/Quote.astro';
|
||||
import Insights from '../components/home/Insights.astro';
|
||||
import Contact from '../components/home/Contact.astro';
|
||||
import { t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.home.title} description={ui.meta.home.description}>
|
||||
<Hero lang={lang} />
|
||||
<Manifesto lang={lang} />
|
||||
<Method lang={lang} />
|
||||
<Stages lang={lang} />
|
||||
<Sectors lang={lang} />
|
||||
<Proof lang={lang} />
|
||||
<Quote lang={lang} />
|
||||
<Insights lang={lang} />
|
||||
<Contact lang={lang} />
|
||||
</Base>
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.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());
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.insights.title} description={ui.meta.insights.description}>
|
||||
<section class="section section--intro">
|
||||
<div class="container grid">
|
||||
<h1 class="display index__title">{ui.insights.title}</h1>
|
||||
<p class="index__lead">{ui.insights.intro}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section--list" aria-label={ui.insights.title}>
|
||||
<div class="container">
|
||||
<ul class="posts" role="list">
|
||||
{posts.map((post) => (
|
||||
<li class="post">
|
||||
<a class="post__link" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
|
||||
<div class="post__media">
|
||||
<Picture
|
||||
src={post.data.cover}
|
||||
alt={post.data.coverAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[400, 640, 960]}
|
||||
sizes="(min-width: 48rem) 40vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="post__img img-doc"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</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>}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.index__title { grid-column: 1 / -1; }
|
||||
.index__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
|
||||
@media (min-width: 48rem) {
|
||||
.index__title { grid-column: 1 / span 8; }
|
||||
.index__lead { grid-column: 1 / span 5; }
|
||||
}
|
||||
.section--list { padding-bottom: var(--section); }
|
||||
.posts { border-top: var(--hairline) solid var(--line); }
|
||||
.post { border-bottom: var(--hairline) solid var(--line); }
|
||||
.post__link {
|
||||
display: grid;
|
||||
gap: var(--space-5) var(--gap);
|
||||
padding-block: var(--space-7);
|
||||
text-decoration: none;
|
||||
}
|
||||
.post__link:hover h2 { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.12em; }
|
||||
.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); }
|
||||
@media (min-width: 48rem) {
|
||||
.post__link { grid-template-columns: 5fr 7fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import type { Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang; title: string; description: string; eyebrow?: string; lead?: string }
|
||||
const { lang, title, description, eyebrow, lead } = Astro.props;
|
||||
---
|
||||
<Base lang={lang} title={title} description={description}>
|
||||
<section class="section">
|
||||
<div class="container grid">
|
||||
<div class="legal__head">
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h1 class="h2">{title}</h1>
|
||||
{lead && <p class="muted legal__lead">{lead}</p>}
|
||||
</div>
|
||||
<div class="prose legal__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.legal__head { grid-column: 1 / -1; display: grid; gap: var(--space-4); align-content: start; }
|
||||
.legal__lead { max-width: var(--measure); }
|
||||
.legal__body { grid-column: 1 / -1; }
|
||||
@media (min-width: 48rem) {
|
||||
.legal__head { grid-column: 1 / span 4; }
|
||||
.legal__body { grid-column: 6 / span 7; }
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.legal__head { grid-column: 1 / span 4; }
|
||||
.legal__body { grid-column: 6 / span 6; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { localePath, t, type Lang } from '../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.notFound.title} description={ui.notFound.body}>
|
||||
<section class="section">
|
||||
<div class="container grid">
|
||||
<h1 class="display nf__title">{ui.notFound.title}</h1>
|
||||
<div class="nf__body">
|
||||
<p class="muted">{ui.notFound.body}</p>
|
||||
<a class="link" href={localePath(lang, '/')}>{ui.notFound.link}</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.nf__title { grid-column: 1 / -1; }
|
||||
.nf__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); }
|
||||
@media (min-width: 48rem) {
|
||||
.nf__title { grid-column: 1 / span 8; }
|
||||
.nf__body { grid-column: 1 / span 5; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { render } from 'astro:content';
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang; post: CollectionEntry<'insights'> }
|
||||
const { lang, post } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const { Content } = await render(post);
|
||||
const d = post.data;
|
||||
---
|
||||
<Base lang={lang} title={d.title} description={d.description}>
|
||||
<article class="post" lang={d.lang}>
|
||||
<header class="section section--intro post__head">
|
||||
<div class="container grid">
|
||||
<div class="post__meta-col">
|
||||
<a class="link" href={localePath(lang, '/insights')} lang={lang}>{ui.insights.back}</a>
|
||||
</div>
|
||||
<div class="post__title-col">
|
||||
<p class="small muted post__meta">
|
||||
<span>{d.category}</span>
|
||||
<time datetime={d.date.toISOString().slice(0, 10)}>{formatDate(d.date, lang)}</time>
|
||||
<span lang={lang}>{ui.insights.readingTime(d.readingTime)}</span>
|
||||
</p>
|
||||
<h1 class="h2 post__title">{d.title}</h1>
|
||||
{d.subtitle && <p class="post__subtitle muted">{d.subtitle}</p>}
|
||||
<p class="post__author"><span lang={lang}>{ui.insights.by}</span> <span class="post__author-name">{d.author}</span><span class="muted">, {d.authorRole}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<Picture
|
||||
src={d.cover}
|
||||
alt={d.coverAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[640, 960, 1440, 1920]}
|
||||
sizes="(min-width: 90rem) 1440px, 100vw"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="post__cover img-doc"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="section container grid">
|
||||
<div class="prose post__body">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.post__meta-col { grid-column: 1 / -1; }
|
||||
.post__title-col { grid-column: 1 / -1; display: grid; gap: var(--space-5); }
|
||||
.post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
||||
.post__title { text-wrap: balance; }
|
||||
.post__subtitle { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); max-width: var(--measure); }
|
||||
.post__author-name { font-weight: var(--weight-medium); }
|
||||
.post__cover, :global(.post__cover) { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; object-position: 50% 12%; }
|
||||
.post__body { grid-column: 1 / -1; }
|
||||
@media (min-width: 48rem) {
|
||||
.post__meta-col { grid-column: 1 / span 2; }
|
||||
.post__title-col { grid-column: 3 / span 9; }
|
||||
.post__body { grid-column: 3 / span 8; }
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
.post__title-col { grid-column: 3 / span 8; }
|
||||
.post__body { grid-column: 3 / span 7; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import teamPhoto from '../assets/images/team.png';
|
||||
import { team } from '../data/team';
|
||||
import { t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.team.title} description={ui.meta.team.description}>
|
||||
<section class="section section--intro team-intro">
|
||||
<div class="container grid">
|
||||
<h1 class="display team-intro__title">
|
||||
{ui.team.title.map((line) => <span class="line">{line}</span>)}
|
||||
</h1>
|
||||
<p class="team-intro__lead">{ui.team.intro}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="team-photo">
|
||||
<Picture
|
||||
src={teamPhoto}
|
||||
alt={ui.team.photoAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[640, 960, 1440]}
|
||||
sizes="100vw"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
class="img-cover img-mono team-photo__img"
|
||||
pictureAttributes={{ class: 'team-photo__picture' }}
|
||||
/>
|
||||
<div class="team-photo__overlay" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<section class="section" aria-label={ui.nav.team}>
|
||||
<div class="container">
|
||||
<ul class="people" role="list">
|
||||
{team.map((p) => (
|
||||
<li class="person">
|
||||
<Picture
|
||||
src={p.portrait}
|
||||
alt={p.name}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
widths={[320, 480, 640]}
|
||||
sizes="(min-width: 64rem) 22vw, (min-width: 48rem) 45vw, 100vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="img-mono img-print person__img"
|
||||
/>
|
||||
<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>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.line { display: block; }
|
||||
.team-intro__title { grid-column: 1 / -1; }
|
||||
.team-intro__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
|
||||
@media (min-width: 48rem) {
|
||||
.team-intro__title { grid-column: 1 / span 7; }
|
||||
.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; }
|
||||
|
||||
.people {
|
||||
display: grid;
|
||||
gap: var(--space-8) var(--gap);
|
||||
}
|
||||
.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)); } }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Legal from '../_Legal.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
const ui = t('de');
|
||||
---
|
||||
<Legal lang="de" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}>
|
||||
<h2>Verantwortlich</h2>
|
||||
<p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Deutschland</p>
|
||||
<p>Telefon: <a href="tel:+498515093218">+49 851 509 3218</a><br />E-Mail: <a href="mailto:felix.klement@ciphercell.de">felix.klement@ciphercell.de</a></p>
|
||||
|
||||
<h2>Inhalt der Website</h2>
|
||||
<p>CipherCell (der Webseitenbetreiber) prüft und aktualisiert die Informationen auf seinen Webseiten regelmäßig. Trotz aller Sorgfalt übernimmt der Webseitenbetreiber keine Haftung oder Garantie für die Aktualität, Richtigkeit, Vollständigkeit oder Qualität der online verfügbaren Informationen. Alle Angebote sind freibleibend und unverbindlich. Der Webseitenbetreiber behält es sich ausdrücklich vor, einzelne Seiten oder das gesamte Onlineangebot ohne vorhergehende Ankündigung zu verändern, zu ergänzen, zu löschen oder das Angebot auf bestimmte Zeit oder auch endgültig einzustellen.</p>
|
||||
|
||||
<h2>Verweise und Links</h2>
|
||||
<p>CipherCell haftet nicht für den Inhalt von fremden Webseiten, auf die im Onlineangebot von CipherCell z. B. mittels Hyperlinks verwiesen wird. Dies gilt auch für alle Einträge Dritter in vom Webseitenbetreiber eingerichteten Gästebüchern, Diskussionsforen, Pinnwänden und Mailinglisten.</p>
|
||||
|
||||
<h2>Urheber- und Kennzeichenrecht</h2>
|
||||
<p>Inhalt und Struktur der Webseite von CipherCell sind urheberrechtlich geschützt. Die Vervielfältigung von Grafiken, Tondokumenten, Bildmaterialien, Film- und Videoausschnitten und Texten in anderen elektronischen oder gedruckten Medien und Publikationen ist ohne die ausdrückliche Erlaubnis von CipherCell nicht gestattet.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Home from '../_Home.astro';
|
||||
---
|
||||
<Home lang="de" />
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../../_Post.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('insights');
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
<Post lang="de" post={post} />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import InsightsIndex from '../../_InsightsIndex.astro';
|
||||
---
|
||||
<InsightsIndex lang="de" />
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import Legal from '../_Legal.astro';
|
||||
import { t } from '../../i18n/ui';
|
||||
const ui = t('de');
|
||||
---
|
||||
<Legal lang="de" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell nimmt die Belange des Datenschutzes ernst und erhebt, verarbeitet und nutzt Ihre personenbezogenen Daten ausschließlich in Übereinstimmung mit den nachfolgend beschriebenen Grundsätzen und unter Beachtung der anwendbaren Datenschutzgesetze.">
|
||||
<h2>Verantwortliche Stelle</h2>
|
||||
<p>Die verantwortliche Stelle für den Betrieb der CipherCell-Website (im Folgenden „Website“) sowie die Erhebung, Verarbeitung und Nutzung Ihrer personenbezogenen Daten im Sinne des anwendbaren Datenschutzrechts ist Dr. Felix Klement, Innstr. 43, 94032 Passau, Deutschland.</p>
|
||||
|
||||
<h2>Webanalyse mit Matomo (ohne Cookies)</h2>
|
||||
<p>Wir verwenden auf unserer Website die Open-Source-Webanalysesoftware Matomo, um das Nutzungsverhalten auf datenschutzfreundliche Weise auszuwerten und unsere Website technisch und inhaltlich zu verbessern. Dabei haben wir Matomo so konfiguriert, dass keine Cookies gesetzt werden und Ihre IP-Adresse anonymisiert gespeichert wird. Es werden keine personenbezogenen Daten verarbeitet, und es erfolgt keine Weitergabe an Dritte. Die Verarbeitung erfolgt auf Grundlage unseres berechtigten Interesses gemäß Art. 6 Abs. 1 lit. f DSGVO an der statistischen Analyse des Nutzerverhaltens zur Optimierung unseres Webangebots. Wenn Ihr Browser die „Do Not Track“-Funktion unterstützt und aktiviert ist, wird Ihr Besuch von Matomo automatisch ignoriert. Sie können der anonymisierten Analyse Ihres Besuchs dennoch jederzeit widersprechen. Nutzen Sie dazu bitte den folgenden Opt-out.</p>
|
||||
<p><a href="https://cipher.uber.space/matomo/index.php?module=CoreAdminHome&action=optOut&language=de" rel="noopener">Matomo Opt-out</a></p>
|
||||
|
||||
<h2>Datensicherheit</h2>
|
||||
<p>Ihre Nutzerdaten sind bei der Übermittlung auf unsere Server durch den Einsatz von TLS in Verbindung mit einer 256-Bit-Verschlüsselung geschützt. Sie können die Sicherheit der Verbindung anhand der Angaben in der Adresszeile Ihres Browsers prüfen. Beginnt die Adresszeile mit „https“, liegt eine gesicherte Verbindung vor.</p>
|
||||
|
||||
<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>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>
|
||||
|
||||
<h2>Anpassungen</h2>
|
||||
<p>CipherCell behält sich vor, diese Datenschutzbestimmungen von Zeit zu Zeit anzupassen. Wir werden Sie von jeder Änderung oder Ergänzung vorab rechtzeitig unterrichten und, soweit erforderlich, Ihre Einwilligung in die geänderte Verarbeitung oder Nutzung Ihrer personenbezogenen Daten einholen.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Team from '../_Team.astro';
|
||||
---
|
||||
<Team lang="de" />
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Legal from './_Legal.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
const ui = t('en');
|
||||
---
|
||||
<Legal lang="en" title={ui.meta.imprint.title} description={ui.meta.imprint.description} eyebrow={ui.legal.imprintEyebrow}>
|
||||
<h2>Responsible</h2>
|
||||
<p>Dr. Felix Klement<br />Innstr. 43<br />94032 Passau<br />Germany</p>
|
||||
<p>Telephone: <a href="tel:+498515093218">+49 851 509 3218</a><br />Email: <a href="mailto:felix.klement@ciphercell.de">felix.klement@ciphercell.de</a></p>
|
||||
|
||||
<h2>Website content</h2>
|
||||
<p>CipherCell (the website operator) regularly reviews and updates the information on its websites. Despite all due care, the website operator accepts no liability or guarantee for the timeliness, accuracy, completeness, or quality of the information available online. All offers are subject to change and non-binding. The website operator expressly reserves the right to change, supplement, delete or discontinue the offer at any time without prior notice. The website operator is not responsible for the content of external links.</p>
|
||||
|
||||
<h2>References and links</h2>
|
||||
<p>CipherCell is not liable for the content of external websites referred to in CipherCell's online offering, e.g. via hyperlinks. This also applies to all entries by third parties in guest books, discussion forums, message boards, and mailing lists set up by the website operator.</p>
|
||||
|
||||
<h2>Copyright and trademark law</h2>
|
||||
<p>The content and structure of the CipherCell website are protected by copyright. The reproduction of graphics, audio documents, image material, film and video excerpts, and texts in other electronic or printed media and publications is not permitted without the express permission of CipherCell. The use of trademarks and logos is also subject to the provisions of the applicable trademark law.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Home from './_Home.astro';
|
||||
---
|
||||
<Home lang="en" />
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../_Post.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('insights');
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
<Post lang="en" post={post} />
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import InsightsIndex from '../_InsightsIndex.astro';
|
||||
---
|
||||
<InsightsIndex lang="en" />
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import Legal from './_Legal.astro';
|
||||
import { t } from '../i18n/ui';
|
||||
const ui = t('en');
|
||||
---
|
||||
<Legal lang="en" title={ui.meta.privacy.title} description={ui.meta.privacy.description} eyebrow={ui.legal.asOf} lead="CipherCell takes data protection seriously and collects, processes and uses your personal data exclusively in accordance with the principles described below and in compliance with applicable data protection law.">
|
||||
<h2>Responsible body</h2>
|
||||
<p>The responsible body for the operation of the CipherCell website (hereinafter referred to as "website") and the collection, processing, and use of your personal data within the meaning of applicable data protection law is Dr. Felix Klement, Innstr. 43, 94032 Passau, Germany.</p>
|
||||
|
||||
<h2>Web analytics with Matomo (without cookies)</h2>
|
||||
<p>We use the open source web analytics software Matomo on our website to evaluate user behavior in a privacy-friendly manner and to improve our website in terms of technology and content. We have configured Matomo so that no cookies are set and your IP address is stored anonymously. No personal data is processed and no data is passed on to third parties. Processing is based on our legitimate interest pursuant to Art. 6 (1) lit. f GDPR in the statistical analysis of user behavior for the purpose of optimizing our website. If your browser supports and has activated the "Do Not Track" function, your visit will be automatically ignored by Matomo. You can still object to the anonymized analysis of your visit at any time. To do so, please use the opt-out below.</p>
|
||||
<p><a href="https://cipher.uber.space/matomo/index.php?module=CoreAdminHome&action=optOut&language=en" rel="noopener">Matomo opt-out</a></p>
|
||||
|
||||
<h2>Data security</h2>
|
||||
<p>Your user data is protected during transmission to our servers through the use of TLS in conjunction with 256-bit encryption. You can check the security of the connection by looking at the information in your browser's address bar. If the beginning of the address line reads "https", the connection is secure.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>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>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>
|
||||
|
||||
<h2>Adjustments</h2>
|
||||
<p>CipherCell reserves the right to amend these data protection provisions from time to time. We will inform you in advance of any changes or additions and, where necessary, obtain your consent to the amended processing or use of your personal data.</p>
|
||||
</Legal>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
import Team from './_Team.astro';
|
||||
---
|
||||
<Team lang="en" />
|
||||
@@ -0,0 +1,214 @@
|
||||
@import './tokens.css';
|
||||
|
||||
/* Self-hosted Inter Tight, variable, latin + latin-ext. */
|
||||
@font-face {
|
||||
font-family: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter-tight-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: 'Inter Tight';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('/fonts/inter-tight-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; }
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
[id] { scroll-margin-top: var(--header-height); }
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--step-body);
|
||||
line-height: var(--leading-body);
|
||||
font-weight: var(--weight-regular);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: 'ss01' off, 'cv11' on;
|
||||
}
|
||||
img, picture, svg, video { display: block; max-width: 100%; }
|
||||
img { height: auto; }
|
||||
input, button, textarea, select { font: inherit; color: inherit; }
|
||||
button { background: none; border: 0; padding: 0; cursor: pointer; }
|
||||
ul[role='list'], ol[role='list'] { list-style: none; padding: 0; }
|
||||
a { color: inherit; text-decoration-color: currentColor; text-underline-offset: 0.14em; text-decoration-thickness: from-font; }
|
||||
h1, h2, h3, h4 { font-weight: var(--weight-medium); overflow-wrap: break-word; }
|
||||
p { overflow-wrap: break-word; }
|
||||
|
||||
/* Focus: one ring, everywhere. */
|
||||
:focus-visible {
|
||||
outline: var(--focus-width) solid currentColor;
|
||||
outline-offset: var(--focus-offset);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:focus:not(:focus-visible) { outline: none; }
|
||||
|
||||
/* Type scale */
|
||||
.display {
|
||||
font-size: var(--step-display);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-display);
|
||||
line-height: var(--leading-display);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.h2 {
|
||||
font-size: var(--step-h2);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-h2);
|
||||
line-height: var(--leading-h2);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.h3 {
|
||||
font-size: var(--step-h3);
|
||||
font-weight: var(--weight-medium);
|
||||
letter-spacing: var(--track-h3);
|
||||
line-height: var(--leading-h3);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.body { font-size: var(--step-body); line-height: var(--leading-body); }
|
||||
.small {
|
||||
font-size: var(--step-small);
|
||||
line-height: var(--leading-small);
|
||||
}
|
||||
.muted { color: var(--ink-muted); }
|
||||
|
||||
/* An eyebrow is sentence case, muted, small. Never all caps. */
|
||||
.eyebrow {
|
||||
font-size: var(--step-small);
|
||||
line-height: var(--leading-small);
|
||||
color: var(--ink-muted);
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
|
||||
/* Primitives */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: var(--container);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
column-gap: var(--gap);
|
||||
row-gap: var(--space-7);
|
||||
}
|
||||
.section {
|
||||
padding-block: var(--section);
|
||||
}
|
||||
.section--tight { padding-block: calc(var(--section) * 0.6); }
|
||||
/* A page head: full air above, less below, so the content follows the title. */
|
||||
.section--intro { padding-block: var(--section) calc(var(--section) * 0.4); }
|
||||
.measure { max-width: var(--measure); }
|
||||
|
||||
/* Night surface */
|
||||
.night {
|
||||
background: var(--night);
|
||||
color: var(--ink-on-night);
|
||||
--ink: var(--ink-on-night);
|
||||
--ink-muted: var(--ink-muted-on-night);
|
||||
--line: var(--line-on-night);
|
||||
}
|
||||
|
||||
/* Hairline rules carry information: they separate, they don't decorate. */
|
||||
.rule { border: 0; border-top: var(--hairline) solid var(--line); }
|
||||
|
||||
/* Links and buttons work through underline, weight and surface. */
|
||||
.link {
|
||||
display: inline-block;
|
||||
font-weight: var(--weight-medium);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: var(--hairline);
|
||||
text-underline-offset: 0.2em;
|
||||
transition: text-underline-offset var(--dur-fast) var(--ease);
|
||||
}
|
||||
.link:hover { text-underline-offset: 0.3em; }
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font-weight: var(--weight-medium);
|
||||
text-decoration: none;
|
||||
line-height: var(--leading-small);
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.button:hover { background: var(--ink-muted); }
|
||||
.button--small { padding: var(--space-2) var(--space-4); font-size: var(--step-small); }
|
||||
.night .button { background: var(--paper); color: var(--night); }
|
||||
.night .button:hover { background: var(--paper-deep); }
|
||||
|
||||
/* Prose: long-form text */
|
||||
.prose { max-width: var(--measure); }
|
||||
.prose > * + * { margin-top: var(--space-5); }
|
||||
.prose h2 {
|
||||
font-size: var(--step-h3);
|
||||
letter-spacing: var(--track-h3);
|
||||
line-height: var(--leading-h3);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
.prose h3 { font-size: var(--step-body); margin-top: var(--space-7); }
|
||||
.prose ul, .prose ol { padding-left: 1.2em; }
|
||||
.prose li + li { margin-top: var(--space-3); }
|
||||
.prose a { text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
|
||||
.prose strong { font-weight: var(--weight-medium); }
|
||||
.prose blockquote {
|
||||
padding-left: var(--space-5);
|
||||
border-left: var(--hairline) solid var(--line);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.prose img { width: 100%; }
|
||||
.prose figcaption { margin-top: var(--space-3); font-size: var(--step-small); color: var(--ink-muted); }
|
||||
|
||||
/* Images */
|
||||
.img-cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
.img-doc { filter: saturate(0.55) contrast(1.02); }
|
||||
.img-mono { filter: grayscale(1) contrast(1.04); }
|
||||
/* Portraits were shot on light grey; multiply lets the paper carry them like a print. */
|
||||
.img-print { mix-blend-mode: multiply; }
|
||||
|
||||
/* Skip link */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: var(--gutter);
|
||||
top: var(--space-3);
|
||||
transform: translateY(-200%);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip-link:focus { transform: none; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* Motion off. No exceptions. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Design tokens. The only place where colours, sizes and durations are defined.
|
||||
Components consume these; a hard-coded hex or px value in a component is a defect. */
|
||||
|
||||
:root {
|
||||
/* Colour: paper, night, image. Nothing else. */
|
||||
--paper: #E9E5DF;
|
||||
--paper-deep: #DFDAD1;
|
||||
--ink: #1A1A18;
|
||||
--ink-muted: #57564F;
|
||||
--night: #0E100F;
|
||||
--line: rgb(26 26 24 / 0.14);
|
||||
|
||||
/* Immutable copies for resetting inside a .night or overlay context */
|
||||
--ink-base: #1A1A18;
|
||||
--ink-muted-base: #57564F;
|
||||
--line-base: rgb(26 26 24 / 0.14);
|
||||
|
||||
/* Counterparts on night surfaces */
|
||||
--ink-on-night: var(--paper);
|
||||
--ink-muted-on-night: rgb(233 229 223 / 0.62);
|
||||
--line-on-night: rgb(233 229 223 / 0.18);
|
||||
|
||||
/* Image overlay */
|
||||
--overlay: linear-gradient(to top, rgb(14 16 15 / 0.75), rgb(14 16 15 / 0.2));
|
||||
|
||||
/* Type */
|
||||
--font-sans: 'Inter Tight', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
--step-display: clamp(2.75rem, 1.2rem + 6.2vw, 7rem);
|
||||
--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;
|
||||
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
|
||||
--track-display: -0.03em;
|
||||
--track-h2: -0.02em;
|
||||
--track-h3: -0.01em;
|
||||
|
||||
--leading-display: 1.02;
|
||||
--leading-h2: 1.08;
|
||||
--leading-h3: 1.2;
|
||||
--leading-title: 1.1;
|
||||
--leading-body: 1.55;
|
||||
--leading-small: 1.4;
|
||||
|
||||
--measure: 34em;
|
||||
|
||||
/* Space */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--space-7: 3rem;
|
||||
--space-8: 4rem;
|
||||
--space-9: 6rem;
|
||||
|
||||
--section: clamp(6rem, 12vw, 11rem);
|
||||
--gutter: clamp(1.25rem, 5vw, 5rem);
|
||||
--gap: clamp(1rem, 2vw, 2rem);
|
||||
--container: 90rem; /* 1440px */
|
||||
|
||||
/* Shape */
|
||||
--hairline: 1px;
|
||||
--radius: 0;
|
||||
--radius-sm: 2px;
|
||||
|
||||
/* Motion */
|
||||
--ease: cubic-bezier(0.2, 0, 0, 1);
|
||||
--dur-fast: 180ms;
|
||||
--dur: 240ms;
|
||||
--dur-slow: 320ms;
|
||||
--dur-hero: 1400ms;
|
||||
|
||||
/* Chrome */
|
||||
--header-height: 4.5rem;
|
||||
|
||||
/* Focus */
|
||||
--focus-width: 2px;
|
||||
--focus-offset: 3px;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||