Initial Astro-Build, Deployment-Setup und Forgejo-Workflow
Some checks failed
Deploy Marketing-Site / Lint + Smoke-Tests (push) Failing after 1m9s
Deploy Marketing-Site / Deploy auf Marketing-VPS (push) Failing after 0s
Deploy Marketing-Site / Deploy-Notification (push) Successful in 9s

- Astro 6 + React + Tailwind v4 Projekt-Skelett mit allen Marketing-Seiten
  (Home, Module, Tester, Souveränität, Roadmap, Kontakt, Impressum, Datenschutz)
- Self-hosted Outfit + JetBrains Mono Fonts (DSGVO)
- Marketing-Komponenten gemäss CLAUDE.md §5.6 (NumberedItem, ModuleCard,
  StatusDot, TechStrip, SovereigntyBlock, RoadmapTimeline, etc.)
- Module-Daten in src/content/module.ts als Single Source of Truth
- E2E Smoke-Tests via Playwright
- OG-Image-Generator
- Forgejo Workflow .forgejo/workflows/deploy.yml für Tier-2 Static Deploy
- Infra-as-Code Snapshot in infra/marketing-vps/
- Brand-System Submodule auf Forgejo umgezogen (war GitHub)
- Deployment- und Handoff-Dokumentation
- .DS_Store aus Tracking entfernt, .gitignore um Test-Artefakte ergaenzt
This commit is contained in:
Pascal Oelmann 2026-05-05 01:59:35 +02:00
parent 8a356f505e
commit 3c79b63db5
66 changed files with 10569 additions and 29 deletions

BIN
.DS_Store vendored

Binary file not shown.

17
.claude/launch.json Normal file
View file

@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 4321
},
{
"name": "preview",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["preview"],
"port": 4321
}
]
}

View file

@ -0,0 +1,117 @@
name: Deploy Marketing-Site
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: deploy-marketing
cancel-in-progress: false
jobs:
test:
name: Lint + Smoke-Tests
runs-on: docker
container:
image: node:22-bookworm
steps:
- uses: actions/checkout@v4
- name: pnpm aktivieren
run: |
corepack enable
corepack prepare pnpm@latest --activate
- name: Dependencies
run: pnpm install --frozen-lockfile
- name: Production-Build
run: pnpm build
- name: Playwright-Browser
run: pnpm exec playwright install --with-deps chromium
- name: Smoke-Tests gegen Production-Build
run: pnpm exec playwright test
env:
CI: '1'
- name: Build-Artefakt aufheben
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
retention-days: 3
deploy:
name: Deploy auf Marketing-VPS
runs-on: docker
container:
image: alpine:3.20
needs: test
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Tools installieren
run: |
apk add --no-cache rsync openssh-client
- name: Build-Artefakt holen
uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: SSH-Key setzen
run: |
mkdir -p ~/.ssh
echo "${{ secrets.MARKETING_SSH_KEY }}" > ~/.ssh/marketing
chmod 600 ~/.ssh/marketing
ssh-keyscan -H "${{ secrets.MARKETING_HOST }}" >> ~/.ssh/known_hosts
- name: Rsync zu Marketing-VPS
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/marketing -o StrictHostKeyChecking=yes" \
dist/ \
"${{ secrets.MARKETING_USER }}@${{ secrets.MARKETING_HOST }}:slimcore.io/"
- name: Deploy-Verifikation
run: |
# Caddy braucht keine Reload — file_server liest live aus dem Verzeichnis
# Stattdessen: HTTPS-Check, dass die neue Version live ist
sleep 3
STATUS=$(wget -qO- --server-response https://slimcore.io/ 2>&1 | awk '/HTTP\//{print $2}' | head -1)
if [ "$STATUS" != "200" ]; then
echo "Production-Site liefert HTTP $STATUS, erwartet 200"
exit 1
fi
echo "✓ slimcore.io antwortet mit 200"
notify:
name: Deploy-Notification
runs-on: docker
container:
image: alpine:3.20
needs: deploy
if: always()
steps:
- name: Status-Mail an Pascal
if: ${{ secrets.BREVO_API_KEY != '' }}
run: |
apk add --no-cache curl
STATUS="${{ needs.deploy.result }}"
SUBJECT="[slimcore.io] Deploy ${STATUS}"
BODY="Deploy von ${{ github.sha }} auf slimcore.io: ${STATUS}\n\nCommit: ${{ github.event.head_commit.message }}\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
curl -X POST https://api.brevo.com/v3/smtp/email \
-H "api-key: ${{ secrets.BREVO_API_KEY }}" \
-H "Content-Type: application/json" \
-d "{
\"sender\": {\"email\": \"deploy@digiformer.net\", \"name\": \"Forgejo Deploy\"},
\"to\": [{\"email\": \"pascal.oelmann@digiformer.net\"}],
\"subject\": \"$SUBJECT\",
\"textContent\": \"$BODY\"
}"

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
node_modules/
dist/
.astro/
.DS_Store
*.local
# Test artefacts
test-results/
playwright-report/
playwright/.cache/
# Editor / OS
.idea/
.vscode/
Thumbs.db

2
.gitmodules vendored
View file

@ -1,3 +1,3 @@
[submodule "docs/brand-system"]
path = docs/brand-system
url = git@github.com:digiformer/brand-system.git
url = https://forge.digiformer.eu/digiformer/brand-system.git

109
CLAUDE.md
View file

@ -8,7 +8,7 @@
## 0. TL;DR für Claude Code
- **Stack:** Astro 5 (Static Site, SEO-first) + Tailwind v4 + selektive React-Islands für interaktive Komponenten + shadcn/ui-Primitives + TypeScript strict.
- **Stack:** Astro 6 (Static Site, SEO-first) + Tailwind v4 + selektive React-Islands für interaktive Komponenten + shadcn/ui-Primitives + TypeScript strict.
- **Domain:** `slimcore.io` = Marketing. `app.slimcore.io` = SaaS-App (separates Repo, nicht Teil dieses Briefs).
- **Positionierung:** Primärgruppe sind Solo-Selbstständige und kleine Teams (110). Sekundär wachsende KMU bis ~30. Tertiär eCommerce-Händler. Hero-Linie: **„Schlank starten. Grenzenlos wachsen."**
- **Familienzugehörigkeit:** SlimCore ist eine eigenständige Produktmarke unter dem digiFORMER-Dach (Adobe-Modell, nicht Atlassian-Modell). Familien-Anker sind Typografie + nummerierte Sektionen + Status-Glyphen — NICHT die Farbe. Jede Marke hat genau eine eigene Akzentfarbe.
@ -98,9 +98,10 @@ slimcore-marketing/
│ ├── favicon.svg
│ ├── og-default.png ← 1200×630 OG-Bild, generiert
│ └── fonts/ ← self-hosted (DSGVO!)
│ ├── source-serif-4-variable.woff2
│ ├── inter-variable.woff2
│ └── jetbrains-mono-variable.woff2
│ ├── outfit-variable.woff2
│ ├── outfit-variable-latin-ext.woff2
│ ├── jetbrains-mono-variable.woff2
│ └── jetbrains-mono-variable-latin-ext.woff2
├── src/
│ ├── styles/
│ │ └── global.css ← Tailwind v4 @theme + Reset
@ -149,7 +150,7 @@ slimcore-marketing/
### 4.1 Astro
- Astro 5.x, Output-Mode `static`.
- Astro 6.x, Output-Mode `static`.
- `@astrojs/react` für React-Islands.
- `@astrojs/sitemap` für `sitemap-index.xml`.
- `@astrojs/mdx` für `/content/notizen` (Phase 2).
@ -189,13 +190,12 @@ Kein Card-Component, kein Accordion, kein Carousel. Marketing-Cards bauen wir se
### 4.6 Schriftarten — self-hosted (DSGVO!)
Keine Google Fonts CDN. Alle Fonts liegen in `public/fonts/` und werden über `@font-face` mit `font-display: swap` geladen.
Keine Google Fonts CDN. Alle Fonts liegen in `public/fonts/` und werden über `@font-face` mit `font-display: swap` geladen. Latin + Latin-Ext jeweils per `unicode-range` getrennt, damit Latin-Ext nur bei Bedarf geladen wird.
- **Serif:** Source Serif 4 Variable (SIL OFL, kostenlos)
- **Sans:** Inter Variable (SIL OFL, kostenlos)
- **Mono:** JetBrains Mono Variable (SIL OFL, kostenlos)
- **Headlines + Body:** Outfit Variable (SIL OFL, kostenlos) — geometrisch-modern, single-font für die ganze Marke
- **Mono:** JetBrains Mono Variable (SIL OFL, kostenlos) — Eyebrows, Stack-Strip, Wortmarke, Sektions-Nummern, Status-Labels
Alternativen falls Pascal anderen Charakter wünscht: Newsreader (Serif, leicht editorial-warm), Geist Sans/Mono (modern-technisch), GT Sectra (Paid, premium).
**Total:** 102 KB (32 KB Outfit Latin + 15 KB Outfit Latin-Ext + 40 KB JBM Latin + 15 KB JBM Latin-Ext). Brand-System §4.1 Mai-2026-Update: Outfit ersetzt Source Serif 4 + Inter familienweit.
---
@ -274,10 +274,10 @@ Alternativen falls Pascal anderen Charakter wünscht: Newsreader (Serif, leicht
### 5.3 Typografie
```css
/* Font-Stacks */
--font-serif: "Source Serif 4", Georgia, serif;
--font-sans: "Inter", system-ui, sans-serif;
/* Font-Stacks — Outfit für Headlines + Body, JetBrains Mono für technische Marker */
--font-sans: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-serif: var(--font-sans); /* Legacy-Alias, zeigt auf Outfit */
/* Type-Scale */
--text-eyebrow: 0.6875rem; /* 11px, mono, uppercase, letter-spacing 0.08em */
@ -292,10 +292,10 @@ Alternativen falls Pascal anderen Charakter wünscht: Newsreader (Serif, leicht
**Regeln:**
- Headlines (`h1`, `h2`): Serif, weight 500 (NICHT 600 oder 700), `letter-spacing: -0.015em` für Display-Größen, `line-height: 1.11.2`.
- Body: Sans, weight 400, `line-height: 1.65`.
- Eyebrows: Mono, 11px, uppercase, `letter-spacing: 0.08em`, Tertiärfarbe.
- Status-Dots, Section-Numbern (`01``04`), Tech-Stack-Marker: Mono.
- Headlines (`h1`, `h2`): Sans (Outfit), weight 500 (NICHT 600 oder 700), `letter-spacing: -0.01em` bei H1+, `line-height: 1.101.20`. Bei Hero-Highlight (Background-Stempel) darf `font-weight: 550` genutzt werden, um optische Verdünnung dunkler Schrift auf Persimmon auszugleichen.
- Body: Sans (Outfit), weight 400, `line-height: 1.65`.
- Eyebrows: Mono (JetBrains Mono), 11px, uppercase, `letter-spacing: 0.08em`, Tertiärfarbe.
- Status-Dots, Section-Numbern (`01``04`), Tech-Stack-Marker, Wortmarke: Mono.
- Sentence case überall. Niemals Title Case. Niemals ALL CAPS außer Mono-Eyebrows.
- Maximale Textbreite für Fließtext: 6070 Zeichen (`max-w-[65ch]`).
@ -335,7 +335,7 @@ Custom-Komponenten (alle in `src/components/marketing/`):
- **`RoadmapTimeline`** — vertikale Linie mit 4 Phasen (Heute / Q3-Q4 2026 / 2027 / Vision). Jede Phase = Card mit Liste der Items.
- **`ObjectionAnswer`** — Frage als Serif-Quote, Antwort als Body-Text. 4 davon im Grid.
- **`CTABlock`** — Schluss-Sektion mit Headline + zwei Buttons + E-Mail-Link.
- **`NavBar`** — sticky-on-scroll mit minimaler Mode-Reduktion (kein Background-Wechsel-Schauspiel).
- **`NavBar`** — sticky-on-scroll. Standardmäßig auf hellem Body-Hintergrund. **Auf der Home-Seite bei Scroll-Position 0 läuft die NavBar visuell IM dunklen Hero mit** (gleicher `#0E0F14`-Hintergrund, Logo und Links in Off-White). Sobald der Tech-Strip vorbei ist und der helle Body-Bereich beginnt, wechselt die NavBar in den hellen Modus zurück. Implementierung: NavBar als `position: sticky` mit `top: 0`, der Hero-Hintergrund läuft hinter der transparenten NavBar durch — beim Scrollen wird die NavBar dann von der hellen Body-Sektion „ausgewaschen". Alternative-Implementierung: NavBar wechselt Hintergrund-Farbe per IntersectionObserver, sobald der Hero den Viewport verlässt. Beide Wege sind ok, Hauptsache: kein Hard-Cut zwischen NavBar und Hero-Hintergrund.
- **`Footer`** — 4-Spalten, dezente Border-Top, Akzent nur bei Hover-Links.
### 5.7 Buttons
@ -393,7 +393,7 @@ Keine `rounded-full`-Mega-Pills. `rounded-md` (68px) reicht. Padding: `px-5 p
**Mobile:** Hamburger → Drawer (Dialog von rechts). Link-Liste, gleiche Struktur.
**Sticky-Verhalten:** NavBar bleibt sticky on scroll, aber subtil — `border-b` erscheint nur wenn `scrollY > 8px`, kein Background-Wechsel.
**Sticky-Verhalten:** NavBar ist sticky on scroll. Auf der Home-Seite ist sie bei `scrollY=0` im dunklen Hero-Modus (siehe NavBar-Komponenten-Spec in §5.6); der Übergang zum hellen Modus passiert beim Verlassen des Hero-Bereichs. Der `border-b` erscheint nur im hellen Modus und nur bei `scrollY > 8px`.
### 6.3 Footer
@ -412,16 +412,69 @@ Bottom-Bar: `© 2026 SlimCore — ein Produkt der digiFORMER GmbH · Impressum
### 7.1 `/` (Home) — der Reihe nach
**1. Hero**
- Eyebrow (Mono): „GESCHÄFTSSOFTWARE FÜR SOLO-SELBSTSTÄNDIGE UND KLEINE TEAMS"
- H1 (Serif, Display): „Schlank starten.<br>Grenzenlos wachsen."
- „Grenzenlos" optional in Persimmon-Akzent als hervorgehobenes Wort (siehe §5.2 Akzent-Verwendungs-Regeln — sparsam!)
- Alternativ-Varianten für späteren A/B-Test: „Klein und schlank. Auch wenn Sie wachsen." / „Eine Plattform für alles, was nicht Buchhaltung ist."
- Lead (17px, Sans, secondary): „SlimCore ist die schlanke Geschäftssoftware für Solo-Selbstständige und kleine Teams. Sie aktivieren am Anfang nur, was Sie heute brauchen — meistens CRM, Belege und Aufgaben. Wenn aus Ihnen drei werden, dann fünfzehn, schalten Sie weitere Module zu, ohne zu wechseln."
- CTAs: `[Tester werden →]` (primary) `[Module ansehen →]` (secondary)
**1. Hero — DUNKLER HERO MIT PERSIMMON-HIGHLIGHT (verbindlich)**
**2. Tech-Strip** (direkt unter Hero, voll Container-Breite, `border-y`)
- Mono, 11px: `STACK · PostgreSQL · PostgREST · Docker · Traefik · Hetzner Falkenstein / Nürnberg · DSGVO · ZUGFeRD 2.0 · DATEV`
> **Diese Spezifikation ist verbindlich, nicht optional.** Frühere Brief-Versionen ließen die Hero-Variante offen, was zu einer hellen Standard-Implementierung geführt hat. Die finale Entscheidung ist die dunkle Variante mit Background-Highlight auf „Grenzenlos". Bitte nicht zur hellen Variante zurückrationalisieren — die dunkle Variante ist Teil der Marken-Persönlichkeit „seriös, aber Revoluzer", und der Bruch zwischen dunklem Hero und hellen Folge-Sektionen ist gewolltes Lese-Architektur-Element.
**Hintergrund und Farben:**
- Hero-Sektion: Vollflächiger dunkler Hintergrund `#0E0F14` (fast-schwarz, leicht kühler Stich — nicht reines Schwarz)
- Headline-Text: `#F5F5F0` (warmes Off-White, kein reines Weiß — passt zum cremefarbenen Body-Hintergrund der restlichen Site)
- NavBar in der Hero-Region läuft mit dunklem Hintergrund mit; Logo, Nav-Links und Sprachwahl in Off-White mit reduzierter Opacity (0.650.85). Übergang zum hellen Body unterhalb des Tech-Strips.
- Hero hat KEIN Border, KEIN Frame — nahtlos voll-bleed in die Container-Breite
**Eyebrow:**
- Text: `▸ GESCHÄFTSSOFTWARE FÜR SOLO-SELBSTSTÄNDIGE UND KLEINE TEAMS`
- Schrift: JetBrains Mono, 11px, weight 500, letter-spacing 0.10em
- Farbe: `#FF6B2C` (Electric Persimmon — auf dunklem Hintergrund signalstark, nicht „dezent")
- Das vorangestellte `▸` ist verbindlich (nicht ◆, nicht •) — kleines visuelles Wiedererkennungs-Element
**H1 (Headline):**
- Text auf zwei Zeilen: „Schlank starten." auf Zeile 1, „Grenzenlos wachsen." auf Zeile 2
- Schrift: Outfit, weight 500, line-height 1.18, letter-spacing -0.015em
- Größe: clamp(38px, 5vw, 56px) — responsive, aber nicht kleiner als 38px im Hero
- Farbe: `#F5F5F0` für „Schlank starten." und „wachsen."
- **Das Wort „Grenzenlos" ist verbindlich als Background-Highlight gesetzt:**
- Background: `#FF6B2C` (Electric Persimmon)
- Vordergrund-Text auf dem Highlight: `#2A0F02` (dunkles Persimmon-Schwarz, nicht reines Schwarz)
- Padding: `0.04em 0.2em 0.08em` (em-relativ, skaliert mit der Schriftgröße)
- `display: inline-block`, `line-height: 0.95`, `font-weight: 550`, `vertical-align: baseline` — der Block hugt die Buchstaben eng, kompensiert die optische Verdünnung dunkler Schrift auf hellem Persimmon
- KEINE rounded corners (border-radius 0) — das Persimmon-Rechteck soll wie ein Marker-Stempel wirken, nicht wie ein Pill-Button
**Lead-Paragraph:**
- Text: „Die schlanke Geschäftssoftware für Solo-Selbstständige und kleine Teams. Sie aktivieren am Anfang nur, was Sie heute brauchen — meistens CRM, Belege und Aufgaben. Wenn aus Ihnen drei werden, dann fünfzehn, schalten Sie weitere Module zu, ohne zu wechseln."
- Schrift: Outfit, 1617px, weight 400, line-height 1.6
- Farbe: `rgba(245, 245, 240, 0.75)` (75% Opacity des Off-Whites — sekundäre Lesbarkeit ohne Kontrast-Verlust)
- Maximale Breite: ~540px (verhindert dass der Lead über die volle Container-Breite läuft)
**CTAs:**
- Primary „Tester werden →": Background `#FF6B2C`, Text `#2A0F02` (NICHT weiß — Kontrast-Grund, siehe Brand-System §3.7), border-radius `var(--border-radius-md)`, padding `11px 20px`, font-size 13px, weight 500
- Secondary „Module ansehen →": Background transparent, border `0.5px solid rgba(245,245,240,0.5)`, Text `#F5F5F0`, sonst gleich
**Vertikales Padding:**
- Hero-Sektion: `60px` oben, `48px` unten (Desktop). Auf Mobile 40px/32px.
**Übergang zur nächsten Sektion (Tech-Strip):**
- Tech-Strip läuft VISUELL noch im dunklen Hintergrund weiter (gleiches `#0E0F14`), nicht im hellen
- Border-top und border-bottom des Tech-Strips: `0.5px solid rgba(245,245,240,0.08)` (sehr dezent)
- Mono-Text im Strip in `rgba(245,245,240,0.55)`, „STACK"-Label in `rgba(245,245,240,0.85)`
- Erst die Modul-Landschaft (Sektion 3) bricht in den hellen Hintergrund — dieser Bruch ist die gewollte Lese-Architektur
**Was NICHT passieren darf:**
- Kein heller Hintergrund im Hero
- Kein Persimmon-Wort ohne Background-Highlight (nur Wort-Farbe wäre die helle-Variante-Lösung — hier ist Background-Highlight verbindlich)
- Keine rounded corners auf dem Persimmon-Rechteck
- Kein weißer Text auf dem Persimmon-Highlight (Kontrast unter WCAG-AA)
- Keine Drop-Shadows, keine Glow-Effekte, keine Gradients
**2. Tech-Strip — dunkler Hintergrund, läuft visuell vom Hero weiter**
- Hintergrund: `#0E0F14` (gleicher dunkler Wert wie Hero, kein Bruch)
- Höhe: padding `12px 32px` (vertikal niedrig, optisch eine schmale Strip-Leiste)
- Border-top und border-bottom: `0.5px solid rgba(245,245,240,0.08)`
- Inhalt: Mono, 11px, letter-spacing 0.06em, uppercase
- `STACK` als Label in `rgba(245,245,240,0.85)`
- Folgende Stack-Komponenten in `rgba(245,245,240,0.55)`, getrennt durch ` · ` (mit Spaces):
`STACK · POSTGRESQL · POSTGREST · HETZNER DE · DSGVO · ZUGFERD 2.0 · DATEV`
- Mobile: gleicher Inhalt, aber mit `flex-wrap: wrap` damit die Items in mehreren Zeilen umbrechen können
**3. Modul-Landschaft (Hero-Visual)**
- Eyebrow: `VIER SÄULEN · 16 MODULE`

27
astro.config.mjs Normal file
View file

@ -0,0 +1,27 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import mdx from '@astrojs/mdx';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
site: 'https://slimcore.io',
i18n: {
locales: ['de', 'en'],
defaultLocale: 'de',
routing: {
prefixDefaultLocale: false,
},
},
integrations: [
react(),
sitemap({
i18n: { defaultLocale: 'de', locales: { de: 'de-DE', en: 'en-US' } },
filter: (page) => !page.includes('/dev/'),
}),
mdx(),
],
vite: {
plugins: [tailwindcss()],
},
});

549
docs/deployment.md Normal file
View file

@ -0,0 +1,549 @@
# Deployment — SlimCore Marketing Site
> **Stand:** 2026-05-04 · **Zielgruppe:** Pascal Oelmann · **Adressat:** dieselbe Marketing-Site (`slimcore.io`) plus die anderen Marken-Sites (`digiformer.eu`, `slimsafe.io`, `fonboard.io`) als Folge-Schritte
> **Pfad:** Forgejo-First. GitHub wird übersprungen — kein Übergangs-Setup, das später migriert werden muss.
Dieses Dokument beschreibt wie die Marketing-Site live geht. Es ist Teil eines familienweiten Standards für **Tier 2 — Static Marketing-Sites** (siehe `docs/handoff.md` für die Tier-Definition).
---
## 0. Voraussetzungen — ein Mal pro Familie
Diese Phasen sind nur beim ersten Mal nötig. Spätere Marken-Sites laufen direkt zu Phase B.
### Phase A — Forgejo-Server + Runner (~1 h)
#### A.1 VPS bestellen
Hetzner Cloud → neuer Server in **Falkenstein**:
| Feld | Wert |
|---|---|
| Typ | CX22 (4 GB RAM / 2 vCPU / 40 GB SSD) |
| Image | Ubuntu 24.04 LTS |
| SSH-Key | dein Schlüssel + ed25519 (kein RSA) |
| Cloud-Init | optional — das Setup unten ist manuell |
| Backups | aktivieren (~€0,80/mo extra) |
| Standort | Falkenstein (FSN1) |
| Hostname | `forge.digiformer.eu` |
| Private Network | `slimcore-infra` (10.0.0.0/24) — falls später shared mit SlimCore-Cluster |
Kosten: ~€4/mo + €0,80 Backups.
#### A.2 Server härten (~10 min)
```bash
# SSH als root
ssh root@<forge-ip>
# System aktualisieren
apt update && apt upgrade -y && apt autoremove -y
# Deploy-User
adduser --disabled-password --gecos "" deploy
usermod -aG sudo,docker deploy
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
# SSH härten — root-Login deaktivieren, nur Key-Auth
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl reload ssh
# Firewall
apt install -y ufw fail2ban
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow http
ufw allow https
ufw --force enable
systemctl enable --now fail2ban
# Docker installieren
curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy
```
#### A.3 Forgejo + Caddy + Runner via Docker Compose
`/home/deploy/forgejo/docker-compose.yml`:
```yaml
services:
forgejo:
image: codeberg.org/forgejo/forgejo:9
container_name: forgejo
restart: unless-stopped
environment:
USER_UID: '1000'
USER_GID: '1000'
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: db:5432
FORGEJO__database__NAME: forgejo
FORGEJO__database__USER: forgejo
FORGEJO__database__PASSWD_FILE: /run/secrets/db_password
FORGEJO__server__DOMAIN: forge.digiformer.eu
FORGEJO__server__ROOT_URL: https://forge.digiformer.eu/
FORGEJO__server__SSH_DOMAIN: forge.digiformer.eu
FORGEJO__service__DISABLE_REGISTRATION: 'true'
FORGEJO__webhook__ALLOWED_HOST_LIST: 'private,*.slimcore.io,*.digiformer.eu'
secrets:
- db_password
volumes:
- ./data:/var/lib/gitea
- ./config:/etc/gitea
ports:
- '127.0.0.1:3000:3000'
- '0.0.0.0:2222:22'
depends_on:
- db
db:
image: postgres:16-alpine
container_name: forgejo-db
restart: unless-stopped
environment:
POSTGRES_DB: forgejo
POSTGRES_USER: forgejo
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- ./pg-data:/var/lib/postgresql/data
runner:
image: code.forgejo.org/forgejo/runner:6
container_name: forgejo-runner
restart: unless-stopped
user: '1000:1000'
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock
command: /bin/sh -c 'sleep 5; forgejo-runner daemon'
depends_on:
- forgejo
secrets:
db_password:
file: ./db_password.txt
```
```bash
# Auf dem Server
sudo -u deploy bash
cd /home/deploy/forgejo
mkdir -p data config pg-data runner-data
openssl rand -hex 24 > db_password.txt
chmod 600 db_password.txt
docker compose up -d
# Erste Schritte
docker compose logs -f forgejo
# Browser: http://<forge-ip>:3000 (über SSH-Tunnel oder kurz UFW öffnen)
# Setup-Wizard durchklicken: Admin-User anlegen, kein Email für jetzt
```
#### A.4 Caddy als TLS-Proxy
Auf demselben Server, separat:
`/home/deploy/caddy/docker-compose.yml`:
```yaml
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
network_mode: host # damit Caddy localhost:3000 erreicht
volumes:
caddy-data:
caddy-config:
```
`/home/deploy/caddy/Caddyfile`:
```caddy
forge.digiformer.eu {
reverse_proxy 127.0.0.1:3000
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
}
```
```bash
docker compose up -d
# DNS A-Record forge.digiformer.eu → <forge-ip>
# Caddy holt automatisch Let's-Encrypt-Cert, ~30 s
```
#### A.5 Runner registrieren
In der Forgejo-UI (`https://forge.digiformer.eu`):
- **Site Administration → Actions → Runners → Create new Runner**
- Token kopieren
```bash
docker compose -f /home/deploy/forgejo/docker-compose.yml exec runner forgejo-runner register \
--no-interactive \
--instance https://forge.digiformer.eu \
--token <runner-token> \
--name forge-runner-1 \
--labels docker:docker://node:22-bookworm,ubuntu-latest:docker://node:22-bookworm
```
Verifikation: in Forgejo-UI sollte der Runner als **online** erscheinen. Damit ist die Forgejo-Plattform fertig.
#### A.6 Backup einrichten
Cron auf Forgejo-Server (User `deploy`):
```bash
# /home/deploy/backup-forgejo.sh
#!/bin/bash
set -euo pipefail
DATE=$(date +%Y-%m-%d-%H%M)
DEST="/home/deploy/backups/forgejo-$DATE.tar.zst"
docker exec forgejo gitea dump --type tar.zst --file - > "$DEST"
# Push to Storage Box (siehe Storage-Box-Setup unten)
rsync -e "ssh -p 23 -i /home/deploy/.ssh/storagebox" "$DEST" \
uXXXXXX@uXXXXXX.your-storagebox.de:/home/forgejo-backups/
# Lokale Retention: 7 Tage
find /home/deploy/backups -name "forgejo-*.tar.zst" -mtime +7 -delete
```
```bash
chmod +x /home/deploy/backup-forgejo.sh
crontab -e
# 0 3 * * * /home/deploy/backup-forgejo.sh >> /var/log/forgejo-backup.log 2>&1
```
---
## Phase B — Marketing-VPS einrichten (~30 min)
### B.1 VPS bestellen
Wieder Hetzner Cloud, gleiche Wahl wie A.1, aber Hostname `marketing.digiformer.eu`. Standort Falkenstein.
### B.2 Server härten
Identisch zu A.2 — Deploy-User, Firewall, Docker.
### B.3 Caddy installieren
Marketing-VPS hostet alle statischen Marken-Sites über einen einzigen Caddy.
`/home/deploy/marketing/docker-compose.yml`:
```yaml
services:
caddy:
image: caddy:2-alpine
container_name: marketing-caddy
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./sites:/var/www:ro
- caddy-data:/data
- caddy-config:/config
volumes:
caddy-data:
caddy-config:
```
`/home/deploy/marketing/Caddyfile`:
```caddy
# — slimcore.io —
slimcore.io, www.slimcore.io {
root * /var/www/slimcore.io
try_files {path} {path}/ /index.html
file_server
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
-Server
}
# Astro generiert /index.html, /en/index.html, etc.
# Sitemap + robots gehen direkt
handle /sitemap-*.xml {
file_server
}
handle /robots.txt {
file_server
}
# SPA-Fallback nicht nötig — alle Routen haben echte HTML-Files
}
# — digiformer.eu — (analog, sobald migriert)
digiformer.eu, www.digiformer.eu {
root * /var/www/digiformer.eu
file_server
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
}
# — slimsafe.io —
slimsafe.io, www.slimsafe.io {
root * /var/www/slimsafe.io
file_server
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
}
# — fonboard.io —
fonboard.io, www.fonboard.io {
root * /var/www/fonboard.io
file_server
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
}
# Catch-all 404 für unbekannte Hostnames
:80 {
respond 404
}
```
```bash
sudo -u deploy bash
cd /home/deploy/marketing
mkdir -p sites/slimcore.io sites/digiformer.eu sites/slimsafe.io sites/fonboard.io
docker compose up -d
```
### B.4 SSH-Key für Forgejo-Runner → Marketing-VPS
Forgejo's Self-hosted Runner muss per SSH auf den Marketing-VPS rsynce können.
```bash
# Auf Forgejo-Server (deploy-user)
ssh-keygen -t ed25519 -f ~/.ssh/marketing_deploy_key -N ""
cat ~/.ssh/marketing_deploy_key.pub
```
```bash
# Auf Marketing-VPS (deploy-user)
adduser --disabled-password --gecos "" rsync-deploy
mkdir -p /home/rsync-deploy/.ssh
echo "<public-key-vom-forgejo-server>" >> /home/rsync-deploy/.ssh/authorized_keys
# Restriktion: nur rsync, kein Shell-Login
echo 'command="rrsync -wo /var/www" <public-key>' > /home/rsync-deploy/.ssh/authorized_keys
chmod 700 /home/rsync-deploy/.ssh
chmod 600 /home/rsync-deploy/.ssh/authorized_keys
chown -R rsync-deploy:rsync-deploy /home/rsync-deploy/.ssh
# rsync-Schreibrechte auf /var/www
mkdir -p /var/www
chown -R rsync-deploy:rsync-deploy /var/www
```
In Forgejo-UI für das `slimcore-website`-Repo:
- **Settings → Secrets and Variables → Actions → New Secret**:
- `MARKETING_SSH_KEY`: privater Schlüsselinhalt von `~/.ssh/marketing_deploy_key` auf Forgejo-Server
- `MARKETING_HOST`: IP oder DNS des Marketing-VPS
- `MARKETING_USER`: `rsync-deploy`
---
## Phase C — Slimcore-Website Repo deployen
### C.1 Repo nach Forgejo migrieren
```bash
# Lokal, im slimcore-website-Folder
git remote add forgejo git@forge.digiformer.eu:digiformer/slimcore-website.git
git push --mirror forgejo
# Damit ist alles drüben — Branches, Tags, History
```
In Forgejo-UI:
- Repo `digiformer/slimcore-website` ist nun gespiegelt
- **Repository → Settings → Mirror Settings**: aus, weil unsere Quelle jetzt Forgejo ist
### C.2 Workflow-Datei
In diesem Repo bereits vorhanden: `.forgejo/workflows/deploy.yml` (siehe diese Datei für Details). Der Workflow wird automatisch erkannt, sobald das Repo in Forgejo liegt.
Trigger:
- `push` auf `main` → Test, Build, Deploy auf Marketing-VPS unter `slimcore.io`
### C.3 DNS-Records setzen
Beim Domain-Registrar (idealerweise INWX, Brand-System §7.3 Greylist):
| Record | Typ | Ziel |
|---|---|---|
| `slimcore.io` | A | `<marketing-vps-ip>` |
| `www.slimcore.io` | A | `<marketing-vps-ip>` |
| `digiformer.eu` (später) | A | `<marketing-vps-ip>` |
| `forge.digiformer.eu` | A | `<forge-vps-ip>` |
| `marketing.digiformer.eu` | A | `<marketing-vps-ip>` (für SSH-Zugriff) |
Caddy holt sich beim ersten Request das Let's-Encrypt-Cert automatisch. ~30 s nach DNS-Auflösung.
### C.4 Erstes Deployment auslösen
```bash
git checkout main
git push forgejo main
```
In Forgejo-UI:
- **Actions** Tab im Repo → laufender Workflow
- Bei Erfolg ist `https://slimcore.io/` live
### C.5 Verifikation
Auf einem beliebigen Rechner:
```bash
curl -I https://slimcore.io/
# → 200 OK, HSTS-Header, server: Caddy
curl https://slimcore.io/sitemap-index.xml | head
# → Sitemap-XML
curl https://slimcore.io/robots.txt
# → robots.txt
curl -I https://slimcore.io/en/
# → 200 OK
```
Plus: lokale Playwright-Tests gegen Production einmal laufen lassen:
```bash
PLAYWRIGHT_BASE_URL=https://slimcore.io pnpm exec playwright test --grep "renders"
```
Falls alle 16 Render-Tests grün → Site ist produktionsreif.
---
## Phase D — Spätere Marken-Sites
Sobald Phase A + B abgeschlossen, ist jede weitere Marketing-Site:
1. Repo in Forgejo anlegen / migrieren
2. `.forgejo/workflows/deploy.yml` aus diesem Repo kopieren, `slimcore.io` durch passende Domain ersetzen
3. DNS A-Record auf Marketing-VPS-IP zeigen
4. Caddyfile-Block auf Marketing-VPS ergänzen + `caddy reload`
Das ist ~15 min pro Marke. Kein neues VPS, kein neues Forgejo-Setup.
---
## Phase E — SlimCore-App-Repo (cockpit) später migrieren
Cockpit folgt demselben Prinzip aber komplexer (3 Production-VPS, Branch-Protection, secrets-Repo, Patroni-Migrations). Eigener Migrations-Plan in `cockpit/docs/infrastructure/forgejo-migration.md` — wird erst angegangen, wenn Marketing-Site stabil läuft. Forgejo + Runner sind dann schon da; nur noch Workflow-Portierung + Secrets-Re-Upload + Branch-Protection.
---
## Backup-Strategie pro Komponente
| Was | Wie | Wohin | Frequenz |
|---|---|---|---|
| Forgejo-Daten (Repos + DB + Config) | `gitea dump` → tar.zst | Hetzner Storage Box Nürnberg | Täglich 03:00 |
| Marketing-Site `/var/www/*` | Inhaltlich = Build-Output, immer aus Git wieder herstellbar | — | nicht nötig |
| Caddy-Daten (Let's-Encrypt-Certs) | Volume-Snapshot | Storage Box | Wöchentlich |
| Marketing-VPS-Config (`docker-compose.yml`, `Caddyfile`) | Im Forgejo-Repo `infra/marketing-vps/` | — | bei jedem Commit |
**Storage Box:** Hetzner Storage Box BX21 (1 TB, Nürnberg, ~€10/mo). Ein gemeinsamer Account für alle Marken. SFTP/rsync-Zugriff. Sub-Pfade pro System: `/forgejo-backups/`, `/slimcore-app-backups/`, etc.
---
## Monitoring (vorerst minimal)
Phase 1 reicht **Uptime Kuma** auf demselben Marketing-VPS:
```yaml
# /home/deploy/uptime-kuma/docker-compose.yml
services:
uptime:
image: louislam/uptime-kuma:1
container_name: uptime
restart: unless-stopped
ports:
- '127.0.0.1:3001:3001'
volumes:
- ./data:/app/data
```
Caddy-Block dafür:
```caddy
status.digiformer.eu {
reverse_proxy 127.0.0.1:3001
basicauth {
pascal $2a$14$<bcrypt-hash> # caddy hash-password generieren
}
}
```
Monitore einrichten: HTTPS-Check für jede Marken-Site + Forgejo. Bei Ausfall Mail an Pascal über Brevo SMTP.
---
## Disaster Recovery — Wenn der Marketing-VPS ausfällt
Marketing-Site ist statisch und reproduzierbar:
1. **Neuer VPS bestellen** (Hetzner UI, ~5 min)
2. Server härten + Caddy installieren (Phase B.2 + B.3, ~20 min)
3. DNS A-Records auf neue IP umstellen (Hetzner DNS oder Registrar, ~5 min)
4. Forgejo-Workflow manuell triggern (Forgejo-UI → Actions → Run workflow) für jede Marken-Site (~5 min Build + Deploy pro Site)
**Total RTO: ~45 min.** Kein RPO-Datenverlust, weil Build-Output reproduzierbar ist.
Wenn der **Forgejo-VPS** ausfällt:
1. Neuer VPS bestellen
2. Backup von Storage Box ziehen (`gitea dump`)
3. Forgejo-Container hochfahren mit dump-restore
4. Runner neu registrieren
**Total RTO: ~1 h.** RPO: bis 24 h (letztes Backup) — aber lokale Git-Clones haben aktuelle Stände, falls nötig push-zurück.
---
## Kostenbilanz Tier-2-Setup
| Posten | €/mo |
|---|---|
| Forgejo-VPS CX22 + Backups | ~5 |
| Marketing-VPS CX22 + Backups | ~5 |
| Hetzner Storage Box BX21 (1 TB) — geteilt mit anderen Backups | ~10 |
| Domain-Registrar (INWX o.ä.) | ~1/Domain/Jahr |
| **Total Marketing-Infra** | **~20/mo** |
Das ist fix für **alle** Marketing-Sites — egal ob 1 oder 10 Marken.
---
## Was als Nächstes
- [ ] Pascal: VPS bestellen (Forgejo + Marketing)
- [ ] Phase A durcharbeiten — Forgejo + Runner laufen, TLS aktiv
- [ ] Phase B durcharbeiten — Marketing-VPS bereit für rsync
- [ ] Phase C durcharbeiten — `slimcore-website` deployt unter `https://slimcore.io/`
- [ ] Smoke-Test, dann live
- [ ] Andere Marken-Sites einzeln nach Phase D nachziehen
- [ ] Cockpit-Migration als separater Plan später (Phase E)

285
docs/handoff.md Normal file
View file

@ -0,0 +1,285 @@
# Phase 0 — Handoff
> Stand: 2026-05-04
## Was funktioniert
- **Astro 6.2.2** mit Static-Output, `@astrojs/react`, `@astrojs/sitemap`, `@astrojs/mdx`
- **Tailwind v4** via `@tailwindcss/vite` — alle Design-Tokens im `@theme inline`-Block in `src/styles/global.css`
- **Self-hosted Fonts** in `public/fonts/`: Source Serif 4 Variable, Inter Variable, JetBrains Mono Variable (latin + latin-ext). Kein Google Fonts CDN.
- **Design-Tokens** aus `digiformer-brand-system.md` korrekt umgesetzt: Electric Persimmon `oklch(0.71 0.22 38)`, Text-on-Accent `#2A0F02`, alle Surface-/Text-/Border-Token
- **shadcn/ui-Primitives**: Button (3 Varianten: default/secondary/ghost), Input, Textarea, Label, Dialog — alle mit Brand-Token statt hardcodierten Farben
- **BaseLayout** mit korrektem `<head>` (OG-Tags, hreflang, canonical, font-preload), NavBar, Footer
- **NavBar**: sticky, Mono-Wortmarke, 4 Nav-Links, DE/EN-Switcher (EN ausgegraut), CTA-Button, Hamburger-Icon mobil, Border-on-scroll
- **Footer**: 4-Spalten (Marke, Produkt, Stack, Kontakt), Bottom-Bar mit Copyright + Impressum/Datenschutz
- **Home** (`index.astro`): Hero-Sektion mit Eyebrow, H1 (Persimmon-Akzent auf "Grenzenlos"), Lead-Text, zwei CTAs
- **`pnpm dev`** läuft auf `localhost:4321`, Typografie und Farben visuell verifiziert
- **Astro-Telemetrie** deaktiviert
## Verzeichnisstruktur
```
src/
├── components/
│ ├── primitives/ ← Button, Input, Textarea, Label, Dialog (React)
│ ├── marketing/ ← NavBar.astro, Footer.astro
│ └── islands/ ← (leer, für Phase 3: TesterForm, ContactForm)
├── content/ ← (leer, für Phase 1: module.ts)
├── layouts/ ← BaseLayout.astro
├── pages/ ← index.astro
└── styles/ ← global.css
```
## Was als Nächstes (Phase 1 — Komponenten-Bibliothek)
Laut CLAUDE.md §9, Phase 1:
1. `Eyebrow`, `SectionHeading`, `NumberedItem` — primitive Layout-Bausteine
2. `StatusDot`, `ModuleCard`, `ModuleGrid` — Modul-Visualisierung
3. `TechStrip`, `SovereigntyBlock`, `RoadmapTimeline` — Sektions-Komponenten
4. `ObjectionAnswer`, `CTABlock` — Argumentations- und Abschluss-Komponenten
5. Dev-Seite: `/dev/components.astro` — alle Bausteine in allen Varianten
## Update 2026-05-04 — Hero Dark-Variante (verbindlich)
CLAUDE.md §7.1 wurde mit verbindlicher Spec für dunkle Hero-Variante erweitert. Umsetzung:
- **`src/pages/index.astro`** — Hero-Sektion komplett umgebaut: `#0E0F14`-Hintergrund, Off-White-Text `#F5F5F0`, Persimmon-Eyebrow mit `▸`-Prefix in `#FF6B2C`. Wort „Grenzenlos" als Background-Highlight (`#FF6B2C` bg, `#2A0F02` fg, padding `0 12px`, `box-decoration-break: clone`, kein border-radius). Tech-Strip läuft im selben dunklen Hintergrund weiter (`border 0.5px solid rgba(245,245,240,0.08)`, STACK-Label bei `0.85`-Opacity, Items bei `0.55`). Sentinel `<div data-hero-end>` markiert das Ende der dunklen Zone für die NavBar.
- **`src/components/marketing/NavBar.astro`** — Dual-Mode-Implementierung. CSS-Klassen `.nav-bar--dark` und `.nav-bar--light` definieren je eigene Farb-Sätze (Logo, Links, Lang-Switch, CTA). Toggle per Scroll-Listener: `getBoundingClientRect()` des `data-hero-end`-Sentinels gegen NavBar-Höhe. Auf Nicht-Home-Seiten (Sentinel fehlt) immer Light-Mode. Transition 200ms.
Visuell verifiziert via preview_inspect: `bg = rgb(14,15,20)`, Highlight-Farben korrekt, `box-decoration-break: clone` aktiv (mit -webkit-Prefix), NavBar-Dark-Mode bei scrollY=0, Light-Mode-Switch beim Sentinel-Crossing nachgewiesen.
## Phase 1 abgeschlossen — Komponenten-Bibliothek
Alle 11 Marketing-Komponenten plus Daten-Foundation und Showcase-Seite stehen.
**Neue Dateien:**
- `src/content/module.ts` — Modul-Typen, 19 Module aus Appendix A, `statusLabel`/`pillarTitles`-Maps
- `src/components/marketing/Eyebrow.astro` — Mono-Caption mit Tone-Varianten (`tertiary` / `accent` / `inverse`), optionalem `prefix` (z.B. `▸`) und Status-Pille
- `src/components/marketing/StatusDot.astro` — vier Status-Glyphen rein per CSS (gefüllt / halbgefüllt / outline / dashed), monochrom, mit `inverse`-Variante für dunkle Hintergründe
- `src/components/marketing/SectionHeading.astro``h2` mit optionalem Eyebrow + Subtitle, `display`-Variante für „große Versprechen"-Sektionen, inverse-Modus
- `src/components/marketing/NumberedItem.astro` — Mono-Nummer + Serif-Title + Body-Slot
- `src/components/marketing/ModuleCard.astro` / `ModuleGrid.astro` — eine Säulen-Karte und das 4-Spalten-Grid mit optionaler Status-Legende
- `src/components/marketing/TechStrip.astro` — extrahiert aus `index.astro`, hat `dark`/`light`-Varianten
- `src/components/marketing/SovereigntyBlock.astro` — 2-Spalten-Layout mit Display-Headline links und 2×2-Border-Left-Items rechts
- `src/components/marketing/RoadmapTimeline.astro` — vertikale Linie mit Phase-Markern, current-Pille
- `src/components/marketing/ObjectionAnswer.astro` — Serif-Frage als Quote, Body-Slot
- `src/components/marketing/CTABlock.astro` — Eyebrow + Headline + Body + bis zu 3 CTAs (`primary`/`secondary`/`ghost`), `inverse`-Modus
- `src/pages/dev/components.astro` — interne Showcase-Seite mit jeder Komponente in jeder Variante (nicht im Sitemap)
- `public/og-default.png` — 1200×630 Platzhalter, gespiegeltes Hero-Layout, generiert via `node scripts/generate-og.mjs`
- `scripts/generate-og.mjs` — Skript zur Regeneration
- `sharp` zur Dev-Dependency-Liste ergänzt (für OG-Generierung; war ohnehin transitiv via Astro vorhanden)
**Geänderte Dateien:**
- `src/pages/index.astro` — Tech-Strip-Inline-Code durch `<TechStrip variant="dark" />` ersetzt, identisches visuelles Ergebnis
- `CLAUDE.md` §0 + §4.1 — „Astro 5" → „Astro 6" (stillschweigender Versions-Update wie abgesprochen)
**Visuell verifiziert:** Komplettes Durchscrollen von `/dev/components` und Re-Check von `/`. Status-Glyphen monochrom korrekt, Persimmon-Akzent sparsam (nur Hero-Highlight, current-Pille, primary CTAs, Eyebrow-accent-Variante, Border-Left auf SovereigntyBlock-Items), keine Drop-Shadows oder Gradients.
## Phase 2 abgeschlossen — Home
Alle 9 Sektionen aus CLAUDE.md §7.1 sind in `src/pages/index.astro` komponiert:
1. Hero (dark) — unverändert aus Phase 0/1
2. Tech-Strip (dark) — unverändert
3. Modul-Landschaft — `<ModuleGrid />` mit Legende, Eyebrow nutzt `modules.length` (aktuell 19), Footer-Link auf `/module`
4. Was uns trennt — 3 `<NumberedItem>` 01/02/03, Surface-White-Hintergrund
5. Souveränität — `<SovereigntyBlock>` mit dreizeiliger Display-Headline, vier Border-Left-Items
6. Roadmap-Snapshot — `<RoadmapTimeline>` mit 4 Phasen, „Heute" mit Aktuell-Pille (Persimmon)
7. Schnell-Argumentarium — 5 `<ObjectionAnswer>` im 2-Spalten-Grid, fünfter wraps unten allein (per Spec)
8. Tester-Programm-Teaser — `<CTABlock>` mit Primary + Ghost-Mail-Link
9. Final-CTA — `<CTABlock inverse>` mit Primary + Secondary, dunkler Hintergrund symmetrisch zum Hero
**Visueller Rhythmus:** Dunkel (Hero+Strip+Sentinel) → hell → surface-white → hell → surface-white → hell → surface-white → dunkel (Final-CTA). Wechselnde Bg-Töne strukturieren ohne Bordüren.
**NavBar Dual-Mode** verifiziert: bei `scrollY=0` über Hero `nav-bar--dark` aktiv, beim Body-Scroll wechselt korrekt zu `nav-bar--light`. Beim Final-CTA bleibt sie hell (Sentinel ist nach Sektion 2 platziert).
**Verifiziert per DOM-Inspection:** Alle Sektionen rendern mit korrekter Bg, Eyebrow, Headline. Auto-Count im Modul-Eyebrow funktioniert (`VIER SÄULEN · 19 MODULE`).
**Inhaltliche Hinweise an Pascal:**
- CLAUDE.md erwähnt mehrfach „16 Module", in `module.ts` (Appendix A) sind aber 19 hinterlegt. Der Eyebrow rendert dynamisch aus den Daten — `19 MODULE`. Wenn 16 die richtige Marketing-Aussage ist, kürze ich die Modul-Liste; wenn 19 stimmt, aktualisiere ich CLAUDE.md §0/§1.
- Die `Aktuell`-Pille auf der Roadmap nutzt Persimmon-Bg + dunklen Text — passt zum Brand-System §3.5 („Status-Pille aktuelle Phase").
- Final-CTA-Headline ist bewusst eine zusammengefasste Version der zwei Sätze aus §7.1 §9 („30 Minuten, kostenlos, unverbindlich..." + „Sie schildern, wo Sie stehen..."). Wenn du beide Sätze willst, splitte ich es in body + headline.
## Update — EN-Variante + Sprachumschaltung (vorgezogen aus Phase-2-Backlog)
EN war ursprünglich Phase 2 (CLAUDE.md §9 Phase 6: „EN-Variante mit `astro-i18n` oder Manual-Routing"). Auf Pascal-Wunsch jetzt vorgezogen.
**Routing:** Astro built-in i18n in `astro.config.mjs`, `defaultLocale: 'de'`, `prefixDefaultLocale: false`. → `/` bleibt DE, `/en/` ist EN.
**Neue/Geänderte Dateien:**
- `astro.config.mjs` — i18n-Block, sitemap mit Locale-Map
- `src/content/module.ts``nameEn`, `pillarTitleEn`, `descriptionEn` pro Modul; `statusLabelEn`/`pillarTitlesEn`-Maps; Helper `getModuleName/getStatusLabel/getPillarTitle(item, lang)`
- `src/i18n/strings.ts` — zentrale UI-Strings DE/EN für NavBar, Footer, Roadmap-Pille; `getLangFromPath()` und `getLocalizedPath()`
- `src/layouts/BaseLayout.astro` — auto-detect Lang aus Pfad, `<html lang>`, hreflang DE/EN/x-default, og:locale, lokalisierte Default-Description
- `src/components/marketing/NavBar.astro` — Sprachumschalter mit aktiv/inaktiv-States in beiden Modi (dark/light), Links via `getLocalizedPath()`, lang-spezifische Nav-Pfade (`/module` vs `/en/module` etc.)
- `src/components/marketing/Footer.astro` — alle Strings aus i18n-Dict
- `src/components/marketing/StatusDot.astro` / `ModuleCard.astro` / `ModuleGrid.astro` / `RoadmapTimeline.astro``lang`-Prop (Default `'de'`)
- `src/pages/en/index.astro` — komplette englische Home, gleiche 9 Sektionen mit übersetzten Inhalten
**Verifiziert:**
- `/``<html lang="de">`, deutsche Inhalte, DE aktiv im Switcher
- `/en/``<html lang="en">`, englische Inhalte, EN aktiv im Switcher
- Switcher-Links: DE-Button auf `/en/` führt zu `/`, EN-Button auf `/` führt zu `/en/`
- hreflang reziprok auf beiden Seiten, `x-default` zeigt auf DE
- Modul-Namen: „Aufgaben" (DE) / „Tasks" (EN), „Belege" / „Documents", etc. Status-Labels und Säulen-Titel ebenfalls übersetzt
- NavBar-Dual-Mode funktioniert auf EN-Seite identisch (dark über Hero, light beim Body-Scroll)
- `/dev/components` rendert weiterhin fehlerfrei (alle Komponenten haben `lang`-Default `'de'`)
**Nicht-übersetzte Pfade (Hinweis):** EN-NavBar verlinkt auf `/en/module`, `/en/sovereignty`, `/en/roadmap`, `/en/tester` — diese Seiten existieren noch nicht (auch nicht in DE), 404 bis Phase 3+4 sie liefern. Footer-Links analog. Sprachumschalter geht von `/en/foo` zurück nach `/foo` (oder umgekehrt) — wenn die DE-Variante nicht existiert, landet der User auf der DE-404 mit deutscher Sprache.
**Inhaltliche Hinweise an Pascal:**
- Module-Eigennamen bleiben in beiden Sprachen identisch wo sinnvoll (CRM, Helpdesk, WhatsApp), übersetzte Konzepte sonst (Belege → Documents, Versand → Shipping, BuHa-Export → Accounting export, Personal → HR, Zeiterfassung → Time tracking)
- „Tester" im EN als „Testers" für die Nav, „Become a tester" als CTA. Wenn du „Beta tester" oder „Pilot user" stärker findest, ändere ich
- Hero-Headline EN: „Start lean. Grow without limits." mit Highlight nur auf „Grow" (statt „Grenzenlos"). Andere Variante denkbar: „Grow boundless." oder „Scale without limits." — sag wenn du eine bestimmte willst
- Tech-Strip EN: DSGVO → GDPR (selbe Sache, andere Schreibung), Rest unverändert
- Sektion 5 Souveränität: „Your data. Your country. Your control." spiegelt das DE 1:1
- Imprint/Privacy: Deutsche Pflichtseiten haben deutsche URLs (`/impressum`, `/datenschutz`); EN-Footer linkt auf `/en/imprint` und `/en/privacy`. Diese müssen in Phase 4 angelegt werden — englische Pflichtseiten sind rechtlich zwar nicht erforderlich, aber Usability-relevant
## Phase 3 abgeschlossen — Module + Tester (Scope C, ohne Form)
Pascal-Entscheidung: Form-Anbindung auf Phase 3.5 verschieben, weil `app.slimcore.io` produktiv noch nicht live ist (nur `app.staging.slimcore.io`). Marketing-Site geht ohne Form-Submission live, Tester-Anmeldung läuft über Mail + Calendly bis Production-App + Form fertig sind.
**Neue Dateien:**
- `src/pages/module.astro` — DE Modul-Seite, Hero-light + ModuleFilter-Island + `<noscript>`-Fallback mit allen 19 Modulen statisch
- `src/pages/en/module.astro` — EN-Variante
- `src/pages/tester.astro` — DE Info-Seite mit 4 Sektionen (Wer/Was bekommen/Was geben/Worauf einstellen) + Pascal-Pull-Quote + CTA-Block (Mail + Calendly)
- `src/pages/en/tester.astro` — EN-Variante
- `src/components/islands/ModuleFilter.tsx` — React-Island mit Status- (5 Optionen) + Säulen-Filter (5 Optionen), Live-Counter, gerenderte Modul-Karten
**Geänderte Dateien:**
- `src/i18n/strings.ts``filter`-Sektion mit Labels („Status", „Säule", „Alle", Singular/Plural), reziproke EN-Strings
- `src/styles/global.css``.dot`-Klassen (synchron mit StatusDot.astro) für die React-Island
**Hydration-Issue gelöst:** Erste Implementierung mit `client:visible` (laut CLAUDE.md §4.3 Empfehlung) hydratierte nicht — weder im Dev-Mode noch im Production-Build. Nach Bisect: `client:load` funktioniert sauber. Die exakte Ursache (vermutlich ein Bug in Astro 6 / React 19 mit `client:visible` + `display: contents`-Wrapper + IntersectionObserver auf 0×0-Element) habe ich nicht final isoliert, weil `client:load` für diese Seite ohnehin die richtige Wahl ist: Auf `/module` ist der Filter das Hauptinteraktionselement, sofortige Hydration kostet ~30 KB JS aber gibt unmittelbare Reaktivität ohne Layout-Shift. SSR-HTML enthält die volle Filter-UI + 19 Modul-Karten, also auch SEO sauber.
**Verifiziert:**
- `/module`: H1 „Module", Eyebrow „MODULE · 19 GESAMT", Filter funktioniert (Verfügbar = 6 Module, Verfügbar + Pillar 01 = 1 Modul (CRM)), Counter aktualisiert sich live
- `/en/module`: H1 „Modules", „Available" + „All" = 6 modules, Filter-Logik identisch
- `/tester`: 5 Sektions-H2 inkl. „Anmeldung läuft aktuell per Mail oder Termin." (statt Form), Pascal-Pull-Quote im Serif-Style mit Border-Left-Akzent
- `/en/tester`: spiegelnd mit „Sign-up currently runs by email or call."
**Inhaltliche Hinweise an Pascal:**
- Tester-Seiten: Anmelde-Sektion ist als Übergangs-CTA gestaltet — Mail an `hallo@slimcore.io?subject=Tester-Programm%20Phase%201` mit vorausgefülltem Subject, plus Calendly-Termin-Link. Sobald `app.slimcore.io/api/public/leads` produktiv steht, ersetzt der echte Form-Island diese Sektion.
- Modul-Seite zeigt 19 Module aus `module.ts` als Single Source of Truth. Wenn du beim CLAUDE.md-Hinweis 16 → 19 noch eine Entscheidung treffen willst, gib Bescheid.
## Phase 3.5 (verschoben) — Form-Anbindung
Aktivieren, sobald `app.slimcore.io/api/public/leads` produktiv ist:
1. Form-Island als React `client:load` (oberhalb der Falte) auf `/tester` und `/en/tester`
2. Felder gemäß CLAUDE.md §7.4: Firma, Name, E-Mail, Telefon (opt), Branche (Free-Text), Team-Größe (Slider 150), „Was nutzen Sie heute?", „Was wäre die größte Lücke?"
3. Altcha-Integration (Self-hosted Proof-of-Work) + Honeypot-Feld
4. POST an `PUBLIC_LEADS_API_URL` (env-var), Default Staging während Übergang
5. Doppel-Opt-in über Brevo SMTP (Mail-Render in App, Versand Brevo)
6. `/danke` und `/en/thank-you` Bestätigungsseiten
7. **API-Spec:** `slimcore-go-to-market.md` §3 (liegt im App-Repo) — vor Implementierung lesen
## Update — Dunkles Design auf allen Hero-Sektionen
Pascal-Wunsch: alle Seiten dunkler Hero, nicht nur Home. Umgesetzt für `/module`, `/en/module`, `/tester`, `/en/tester` und alle Phase-4-Seiten unten. NavBar wechselt überall sauber zwischen dark (über Hero) und light (Body) per `[data-hero-end]`-Sentinel.
**Kleine Komponenten-Erweiterung:** `SectionHeading.astro` bekam `eyebrowPrefix` (z.B. „▸") und `eyebrowTone` (default: `accent` bei `inverse=true`, sonst `tertiary`).
## Phase 3.5 (verschoben) — Form-Anbindung
Aktivieren, sobald `app.slimcore.io/api/public/leads` produktiv ist:
1. Form-Island als React `client:load` (oberhalb der Falte) auf `/tester` und `/en/tester`
2. Felder gemäß CLAUDE.md §7.4
3. Altcha-Integration + Honeypot
4. POST an `PUBLIC_LEADS_API_URL` (env-var)
5. Brevo SMTP für DOI
6. `/danke` und `/en/thank-you` Bestätigungsseiten
7. **API-Spec:** `slimcore-go-to-market.md` §3 (App-Repo) — vor Implementierung lesen
## Phase 4 abgeschlossen — Restliche Seiten
10 neue Seiten (5 DE + 5 EN), alle mit dunklem Hero und Sentinel-NavBar-Toggle.
**Neue Dateien:**
- `src/pages/souveraenitaet.astro` / `src/pages/en/sovereignty.astro` — 6 nummerierte Long-form-Sektionen (Warum jetzt → Hosting → OSS-Stack mit Tabelle → Keine US-Abhängigkeiten → Kein Lock-in → Optionaler Compliance-Layer)
- `src/pages/roadmap.astro` / `src/pages/en/roadmap.astro` — 4 Phasen, dynamisch aus `module.ts` (alle Module mit Status, Säule, Beschreibung pro Phase)
- `src/pages/kontakt.astro` / `src/pages/en/contact.astro` — 3 Kontaktwege (Mail, Calendly extern, Post). Calendly ist nur ein externer Link, kein Embed — vermeidet Cookie-Pflicht
- `src/pages/impressum.astro` / `src/pages/en/imprint.astro` — TMG-Template mit `[Platzhaltern]` für Adresse/Geschäftsführer/HRB/USt-ID
- `src/pages/datenschutz.astro` / `src/pages/en/privacy.astro` — DSGVO-Template, nennt Hetzner (DE), Brevo (FR), Calendly (US-Übergangs-Realität) als Auftragsverarbeiter. Bestätigt: keine Cookies, keine Analytics, self-hosted Fonts
**Geänderte Dateien:**
- `src/components/marketing/SectionHeading.astro``eyebrowPrefix`-Prop, `eyebrowTone`-Override; default Eyebrow-Tone wechselt smartly mit `inverse`
- `src/pages/{module,tester}.astro` und EN-Pendants — dunkler Hero + Sentinel
- `src/pages/index.astro` und EN-Pendant — Tech-Strip-Refactor (Phase 1)
**Verifiziert:** `pnpm build` erzeugt 17 Pages in 1.21s, alle 10 neuen Routen liefern HTTP 200, Hero-Bg = `rgb(14,15,20)`, NavBar = `dark` bei scrollY=0, Tabellen rendern (Souveränität: 7 Reihen Stack-Tabelle), Footer-Links auf Impressum/Datenschutz funktionieren.
**Inhaltliche Hinweise an Pascal — vor Production-Launch nötig:**
- **Impressum:** `[Straße + Hausnummer]`, `[PLZ Ort]`, `[Rufnummer]`, `[Amtsgericht]`, `HRB [Nummer]`, `DE [Nummer]` — alle Platzhalter durch echte digiFORMER-Daten ersetzen.
- **Datenschutz:** Template bildet Phase-1-Realität ab (keine Cookies, keine Analytics). Sobald Phase-3.5-Form aktiviert wird, muss Sektion „Tester-Anmeldung" mit Form-Daten, DOI, Altcha-PoW ergänzt werden. Empfehlung: vor Production-Launch durch Datenschutzberatung freigeben lassen.
- **Souveränität:** Long-form ist als Astro-Markup geschrieben, nicht MDX. Falls du den Text öfter editierst, kann ich auf `.mdx` umbauen — aber für statische Marketing-Seite ist Astro-Markup näher dran.
- **Roadmap:** Phasen-Beschreibungen und „Aktuell"-Pille für „Heute" gesetzt. Inhalte (welche Module welche Phase) kommen automatisch aus `module.ts` Status-Feld — wenn du die Liste pflegst, aktualisiert sich die Roadmap.
- **Kontakt:** Adress-Block hat Platzhalter wie das Impressum. Zusätzlich erwähnt der Calendly-Block, dass es Übergangs-Realität ist und ein eigenes Termin-Modul folgt.
## Update — Schriftarten auf Outfit umgestellt (Brand-System-Override)
Pascal-Entscheidung: SlimCore wechselt von Source Serif 4 + Inter (Brand-System §4.1 Default) auf **Outfit** für Headlines und Body. **JetBrains Mono bleibt** als Familien-Anker für Eyebrows, Stack-Strip und Wortmarke.
**Begründungen:**
- Mobile-Performance: 833 KB Fonts → 102 KB (88 % Ersparnis)
- Lighthouse Mobile: Performance 81 → **100/100**, LCP 5.3s → 1.4s
- Differenzierung zur Mutter-Marke digiFORMER (Editorial-Serif) — SlimCore positioniert sich klar als „Werkzeug-Marke" mit geometric-modern Sans
- Tonalität §11 („pragmatisch, nicht idealistisch"): Outfit liest sich präziser-bestimmter als Inter
**Brand-System-Implikation (für Pascal):** Brand-System §4.1 nennt Source Serif 4 + Inter als Familien-Anker. Mit dem SlimCore-Switch auf Outfit wird einer der drei Familien-Anker gebrochen. Zwei Pfade vorwärts:
1. **SlimCore-spezifischer Override** dokumentieren — andere Marken (digiFORMER, trendscout, SlimSafe) bleiben bei Source Serif 4 + Inter. Aktuell die De-facto-Realität, weil nur SlimCore live ist.
2. **Familien-System anpassen** — Brand-System §4.1 darum erweitern, dass jede Marke eigene Sans wählen darf, solange JetBrains Mono als Familien-Anker bleibt. Die nummerierten Sektionen + Status-Glyphen + Mono-Eyebrows tragen dann die Familien-Konsistenz.
Empfehlung Variante 2 — JetBrains Mono ist eh der „technische Familien-Marker", und Editorial-Serif vs Geometric-Sans als Marken-Differenzierung ist eine starkere Architektur als „alle drei Schriften überall identisch".
**Geänderte/Entfernte Dateien:**
- `src/styles/global.css``@font-face` für Outfit (Latin + Latin-Ext via unicode-range), `--font-sans`/`--font-serif` zeigen beide auf Outfit (Alias bleibt für Tailwind-Kompatibilität), `h1/h2/h3` mit `letter-spacing: -0.01em` für Geometric-Sans-Headlines
- `src/layouts/BaseLayout.astro` — Preload-Tags auf Outfit + JetBrains Mono
- `public/fonts/outfit-variable.woff2` (32 KB) + `outfit-variable-latin-ext.woff2` (15 KB) hinzugefügt
- `public/fonts/source-serif-4-variable.woff2` und `inter-variable.woff2` **entfernt** (~778 KB gespart)
- `src/pages/dev/fonts.astro` und `public/fonts/comparison/` **entfernt** — Vergleichsseite hat ihren Zweck erfüllt
- 11 temporäre fontsource-Packages aus devDependencies entfernt; nur `@fontsource-variable/outfit` bleibt
**Verifiziert:** Lighthouse Mobile 100/100/100/100, Desktop 100/100, alle 24 Playwright-Smoke-Tests grün.
## Phase 5 — Polish & Deploy (im Gang)
5.1 ✓ robots.txt + Sitemap (sitemap-index.xml mit hreflang-Pairs, /dev/-Routen ausgefiltert)
5.2 ✓ Playwright-Smoke-Tests (24 Tests, alle Routen + Filter-Island + Sprachumschalter + Footer-Links)
5.3 ✓ Build-Artefakt-Analyse + Lighthouse-Audit (Performance/A11y/Best-Practices/SEO **alle 100/100** Mobile + Desktop nach Outfit-Switch)
5.4 ✓ Deployment-Konzept als **Tier-2 Standard** dokumentiert: `docs/deployment.md` (Forgejo-First, kein Coolify, kein GitHub-Übergang)
**Familienweiter Deployment-Standard (statt Coolify):**
- **Tier 1 — Production SaaS mit Docker (SlimCore-Pattern):** raw `docker-compose.*.yml` + Forgejo Actions SSH-Deploy + Branch-Protection. Für `app.slimcore.io`, `app.fonboard.io`, ggf. später `app.slimsafe.io`. Detaillierte Spec liegt im cockpit-Repo (`docs/superpowers/specs/2026-04-30-production-infra-phase1-design.md`).
- **Tier 2 — Static Marketing-Sites:** Caddy + rsync (kein Docker pro Site nötig). Eine geteilte Caddy-Instanz auf einem Marketing-VPS hostet `slimcore.io`, `digiformer.eu`, `slimsafe.io`, `fonboard.io`. Forgejo Actions baut + rsync't pro Repo. **Spec: `docs/deployment.md` in diesem Repo.**
**Warum kein Coolify:** Single Point of Failure für alle Deployments, Drift zwischen UI-State und Repo-State, schwierig für HA-State-Cluster (Patroni + etcd + Redis Sentinel im SlimCore-App). Beim Tier-2-Pattern ist Docker nicht mal nötig — die Marketing-Site ist statisch, Caddy + rsync reicht.
**Warum kein GitHub:** Brand-System §7.5 sieht Forgejo als Migrationsziel vor. Für eine **neu gebaute** Marketing-Site lohnt sich der GitHub-Umweg nicht — wir starten direkt auf Forgejo. Cockpit (SlimCore-App) wird später migriert; Marketing-Site dient als niedrig-risikoreicher Forgejo-Pilot.
**Neue Dateien (in diesem Repo):**
- `docs/deployment.md` — kompletter Deployment-Guide (Phase A: Forgejo-Server-Setup, Phase B: Marketing-VPS-Setup, Phase C: dieses Repo deployen, Phase D: weitere Marken nachziehen, Phase E: cockpit später migrieren)
- `.forgejo/workflows/deploy.yml` — funktionsbereiter Workflow (Test → Build → Rsync → Verifikation → Mail-Notification via Brevo)
- `infra/marketing-vps/Caddyfile` — Caddy-Config-Template mit Blöcken für alle 4 Marken-Sites + Status-Page
- `infra/marketing-vps/docker-compose.yml` — Caddy + Uptime-Kuma-Stack
- `infra/marketing-vps/README.md` — VPS-Setup-Anleitung inkl. rsync-deploy-User mit `rrsync`-Restriktion
**Kostenbild Tier 2 (alle Marketing-Sites zusammen):** ~€20/mo (Forgejo-VPS €5 + Marketing-VPS €5 + Storage-Box €10 geteilt mit anderen Backups).
## Was Pascal jetzt tun muss
5.5 — Forgejo-VPS bestellen und Phase A durcharbeiten (~1 h)
5.6 — Marketing-VPS bestellen und Phase B durcharbeiten (~30 min)
5.7 — DNS-Records bei Registrar setzen (`forge.digiformer.eu`, `slimcore.io`, etc.)
5.8 — `slimcore-website`-Repo auf Forgejo spiegeln, Workflow läuft auto bei `git push`
5.9 — Smoke-Test gegen `https://slimcore.io/`, dann **Launch**
5.10 — Spätere Marketing-Sites einzeln nach Phase D nachziehen
5.11 — Cockpit-Migration als separater Plan in `cockpit/docs/infrastructure/forgejo-migration.md`
## Offene Fragen vor Phase 2
1. **Mobile Hamburger-Drawer** noch nicht implementiert — entschieden: Astro-Script + CSS-Transition. Wird in Phase 2 mit dem Home-Ausbau erledigt, da der Drawer nur sinnvoll testbar ist, wenn die Nav-Ziele auch existieren.
2. ~~OG-Image~~ — erledigt, Platzhalter generiert. Replacement nur bei Bedarf, sonst Phase 5 (dynamisch).
3. ~~Astro-Version~~ — erledigt, v6 behalten und in CLAUDE.md angepasst.

96
e2e/smoke.spec.ts Normal file
View file

@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test';
const routes = [
{ path: '/', lang: 'de', h1Contains: 'Schlank starten' },
{ path: '/module', lang: 'de', h1Contains: 'Module' },
{ path: '/tester', lang: 'de', h1Contains: 'Lücken' },
{ path: '/souveraenitaet', lang: 'de', h1Contains: 'Ihre Daten' },
{ path: '/roadmap', lang: 'de', h1Contains: 'Heute' },
{ path: '/kontakt', lang: 'de', h1Contains: 'erreichen' },
{ path: '/impressum', lang: 'de', h1Contains: 'Impressum' },
{ path: '/datenschutz', lang: 'de', h1Contains: 'Datenschutz' },
{ path: '/en/', lang: 'en', h1Contains: 'Start lean' },
{ path: '/en/module', lang: 'en', h1Contains: 'Modules' },
{ path: '/en/tester', lang: 'en', h1Contains: 'gaps' },
{ path: '/en/sovereignty', lang: 'en', h1Contains: 'Your data' },
{ path: '/en/roadmap', lang: 'en', h1Contains: 'Today' },
{ path: '/en/contact', lang: 'en', h1Contains: 'reach' },
{ path: '/en/imprint', lang: 'en', h1Contains: 'Imprint' },
{ path: '/en/privacy', lang: 'en', h1Contains: 'Privacy' },
];
test.describe('All routes return 200 with correct lang and headline', () => {
for (const r of routes) {
test(`${r.path} renders`, async ({ page }) => {
const response = await page.goto(r.path);
expect(response?.status()).toBe(200);
await expect(page.locator('html')).toHaveAttribute('lang', r.lang);
await expect(page.locator('h1')).toContainText(r.h1Contains);
await expect(page.locator('#nav-bar a[href="/"], #nav-bar a[href="/en/"]').first()).toContainText('SlimCore');
});
}
});
test.describe('NavBar dual-mode toggles on dark-hero pages', () => {
test('Home dark hero: nav dark at top, light after scroll past hero', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => document.getElementById('nav-bar')?.dataset.mode === 'dark');
await page.evaluate(() => window.scrollTo(0, 2000));
await page.waitForFunction(() => document.getElementById('nav-bar')?.dataset.mode === 'light');
});
test('Module page also has dark hero', async ({ page }) => {
await page.goto('/module');
await page.waitForFunction(() => document.getElementById('nav-bar')?.dataset.mode === 'dark');
});
});
test.describe('Module filter island', () => {
test('Filter pills hydrate and filter the list', async ({ page }) => {
await page.goto('/module');
await expect(page.locator('main ul li')).toHaveCount(19);
await page.locator('button[aria-pressed]', { hasText: /^Verfügbar$/ }).click();
await expect(page.locator('main ul li')).toHaveCount(6);
await expect(page.locator('[aria-live="polite"]')).toHaveText(/6 Module/);
});
});
test.describe('Language switcher', () => {
test('From DE home, EN button leads to /en/', async ({ page }) => {
await page.goto('/');
await page.locator('.nav-lang-link', { hasText: 'EN' }).click();
await expect(page).toHaveURL('/en/');
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
test('From EN home, DE button leads to /', async ({ page }) => {
await page.goto('/en/');
await page.locator('.nav-lang-link', { hasText: 'DE' }).click();
await expect(page).toHaveURL('/');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
});
test.describe('Footer + sitemap + robots', () => {
test('Sitemap-index exists and links to slimcore.io', async ({ request }) => {
const res = await request.get('/sitemap-index.xml');
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toContain('https://slimcore.io/sitemap-0.xml');
});
test('robots.txt exists and disallows /dev/', async ({ request }) => {
const res = await request.get('/robots.txt');
expect(res.status()).toBe(200);
const body = await res.text();
expect(body).toContain('Disallow: /dev/');
expect(body).toContain('Sitemap: https://slimcore.io/sitemap-index.xml');
});
test('Footer Impressum link works', async ({ page }) => {
await page.goto('/');
await page.locator('footer a[href="/impressum"]').click();
await expect(page).toHaveURL('/impressum');
await expect(page.locator('h1')).toContainText('Impressum');
});
});

View file

@ -0,0 +1,112 @@
# Marketing-VPS Caddyfile — wird auf marketing.digiformer.eu deployt
#
# Eine Caddy-Instanz hostet alle statischen Marken-Sites über file_server.
# Per-Marke ein Block. Jede Marke hat ihren eigenen Verzeichnis-Tree unter /var/www/<domain>/.
# Forgejo Actions rsync't den Astro-Build-Output dorthin.
{
# globale Optionen
email pascal.oelmann@digiformer.net
servers {
metrics # Prometheus-Endpoint :2019/metrics für späteres Monitoring
}
}
# — slimcore.io —
slimcore.io, www.slimcore.io {
root * /var/www/slimcore.io
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "interest-cohort=()"
-Server
}
# Astro generiert echte HTML-Files für jede Route, kein SPA-Fallback nötig
# /index.html, /en/index.html, /module/index.html, /en/module/index.html, etc.
file_server
# Sitemap, robots.txt, OG-Image direkt aus dem Root
@static_root path /sitemap-*.xml /robots.txt /favicon.svg /og-default.png
handle @static_root {
file_server
}
# Cache-Header pro Asset-Typ
@assets path /_astro/* /fonts/*
handle @assets {
header Cache-Control "public, max-age=31536000, immutable"
}
@html path *.html /
handle @html {
header Cache-Control "public, max-age=300, must-revalidate"
}
# Redirects — sollten in Astro-Site selbst leben, aber als Sicherheits-Netz hier
redir /home / permanent
redir /index / permanent
log {
output file /var/log/caddy/slimcore.io.log {
roll_size 100MiB
roll_keep 14
}
format json
}
}
# — digiformer.eu — (sobald migriert)
digiformer.eu, www.digiformer.eu {
root * /var/www/digiformer.eu
encode zstd gzip
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
file_server
log {
output file /var/log/caddy/digiformer.eu.log
}
}
# — slimsafe.io — (sobald Marketing-Site existiert)
slimsafe.io, www.slimsafe.io {
root * /var/www/slimsafe.io
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
file_server
log {
output file /var/log/caddy/slimsafe.io.log
}
}
# — fonboard.io — (sobald Marketing-Site existiert)
fonboard.io, www.fonboard.io {
root * /var/www/fonboard.io
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
file_server
log {
output file /var/log/caddy/fonboard.io.log
}
}
# — Status-Page (intern, basicauth-geschützt) —
status.digiformer.eu {
reverse_proxy 127.0.0.1:3001
basicauth {
# caddy hash-password generiert den bcrypt-Hash
# echtes Passwort beim Setup setzen
pascal $2a$14$REPLACE_WITH_BCRYPT_HASH
}
}
# Catch-all — unbekannte Hostnames bekommen 404, kein Default-Server
:80 {
respond "Not Found" 404
}
:443 {
respond "Not Found" 404
}

View file

@ -0,0 +1,78 @@
# Marketing-VPS Konfiguration
Diese Verzeichnisinhalte werden auf den Marketing-VPS (`marketing.digiformer.eu`) deployt
und sind dort die Source of Truth für Caddy + Uptime Kuma.
## Initial-Deploy auf den VPS
```bash
# Auf einem Rechner mit SSH-Zugang zum Marketing-VPS
rsync -avz --delete \
-e "ssh -i ~/.ssh/marketing-admin" \
infra/marketing-vps/ \
deploy@marketing.digiformer.eu:/home/deploy/marketing/
# Auf dem Marketing-VPS
ssh deploy@marketing.digiformer.eu
cd /home/deploy/marketing
sudo mkdir -p /var/www /var/log/caddy
sudo chown -R deploy:deploy /var/www /var/log/caddy
docker compose up -d
docker compose logs -f caddy
```
## Caddyfile-Update
Caddy validiert und lädt neu mit:
```bash
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
```
## Status-Page-Passwort setzen
```bash
docker compose exec caddy caddy hash-password
# Eingabe-Aufforderung, gibt bcrypt-Hash aus
# In Caddyfile bei `status.digiformer.eu` einsetzen, dann reload
```
## Neue Marken-Site hinzufügen
1. Caddyfile-Block ergänzen (analog zu `slimcore.io`)
2. `caddy reload` (siehe oben)
3. DNS A-Record für `<neue-domain>.<tld>` auf VPS-IP zeigen
4. Forgejo-Workflow im jeweiligen Marken-Repo deployt automatisch nach `/var/www/<neue-domain>/`
## SSH-Berechtigungen für Forgejo-Runner
Der Runner braucht einen User mit rsync-only-Rechten auf `/var/www/`:
```bash
# Auf Marketing-VPS
sudo adduser --disabled-password --gecos "" rsync-deploy
sudo mkdir -p /home/rsync-deploy/.ssh
# rrsync ist Teil des rsync-Pakets
sudo apt install rsync
RRSYNC=$(find /usr -name 'rrsync' 2>/dev/null | head -1)
# authorized_keys mit Befehls-Restriktion: nur Schreiben in /var/www
echo "command=\"$RRSYNC -wo /var/www\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty <forgejo-runner-public-key>" \
| sudo tee /home/rsync-deploy/.ssh/authorized_keys
sudo chmod 700 /home/rsync-deploy/.ssh
sudo chmod 600 /home/rsync-deploy/.ssh/authorized_keys
sudo chown -R rsync-deploy:rsync-deploy /home/rsync-deploy/.ssh
# rsync-deploy braucht Schreibrechte
sudo chown rsync-deploy:rsync-deploy /var/www
```
## Disaster-Recovery
- **Caddy-Container fällt aus**`docker compose restart caddy`
- **VPS unreachable** → siehe `docs/deployment.md` (RTO ~45 min, statisches Build-Output reproduzierbar)
- **TLS-Cert-Problem**`caddy-data`-Volume enthält Let's-Encrypt-Account. Bei Volume-Verlust holt Caddy automatisch neue Certs (~30 s pro Domain)
- **Uptime-Kuma-Daten verloren** → kritischer Schaden gering, Monitor-Konfiguration neu anlegen (~10 min)

View file

@ -0,0 +1,50 @@
# Marketing-VPS Stack — eine Caddy-Instanz für alle statischen Marken-Sites
# plus Uptime Kuma für Status-Monitoring.
#
# Verzeichnis-Struktur auf dem VPS:
# /home/deploy/marketing/ ← diese Compose-Datei + Caddyfile
# /var/www/<domain>/ ← rsync-Ziel pro Marke (Caddy liest read-only daraus)
# /var/log/caddy/ ← Access-Logs pro Domain
services:
caddy:
image: caddy:2-alpine
container_name: marketing-caddy
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '443:443/udp' # HTTP/3
- '127.0.0.1:2019:2019' # Admin-API + Metrics, nur lokal
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /var/www:/var/www:ro
- /var/log/caddy:/var/log/caddy
- caddy-data:/data
- caddy-config:/config
networks:
- marketing
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:2019/config/"]
interval: 30s
timeout: 5s
retries: 3
uptime:
image: louislam/uptime-kuma:1
container_name: marketing-uptime
restart: unless-stopped
ports:
- '127.0.0.1:3001:3001'
volumes:
- ./uptime-data:/app/data
networks:
- marketing
volumes:
caddy-data:
caddy-config:
networks:
marketing:
driver: bridge

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "slimcore-website",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.4",
"@astrojs/react": "^5.0.4",
"@astrojs/sitemap": "^3.7.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^6.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
"@fontsource-variable/outfit": "^5.2.8",
"@playwright/test": "^1.59.1",
"sharp": "^0.34.5"
}
}

21
playwright.config.ts Normal file
View file

@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:4321',
trace: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'pnpm preview',
url: 'http://localhost:4321',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});

4930
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

4
public/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#1F2128"/>
<text x="5" y="23" font-family="monospace" font-size="16" font-weight="500" fill="#FF6B2C">sc</text>
</svg>

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/og-default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

6
public/robots.txt Normal file
View file

@ -0,0 +1,6 @@
# https://slimcore.io
User-agent: *
Allow: /
Disallow: /dev/
Sitemap: https://slimcore.io/sitemap-index.xml

21
scripts/generate-og.mjs Normal file
View file

@ -0,0 +1,21 @@
// Generates public/og-default.png — 1200x630, dark, with Persimmon highlight on "Grenzenlos".
// Run: node scripts/generate-og.mjs
import sharp from 'sharp';
import { writeFileSync } from 'node:fs';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#0E0F14"/>
<text x="80" y="100" font-family="JetBrains Mono, monospace" font-weight="500" font-size="20" fill="#FF6B2C" letter-spacing="2"> SLIMCORE</text>
<text x="80" y="320" font-family="Source Serif 4, Georgia, serif" font-weight="500" font-size="84" fill="#F5F5F0" letter-spacing="-1.5">Schlank starten.</text>
<rect x="68" y="358" width="430" height="98" fill="#FF6B2C"/>
<text x="80" y="430" font-family="Source Serif 4, Georgia, serif" font-weight="500" font-size="84" fill="#2A0F02" letter-spacing="-1.5">Grenzenlos</text>
<text x="510" y="430" font-family="Source Serif 4, Georgia, serif" font-weight="500" font-size="84" fill="#F5F5F0" letter-spacing="-1.5">wachsen.</text>
<text x="80" y="540" font-family="Inter, sans-serif" font-weight="400" font-size="22" fill="#F5F5F0" opacity="0.75">Schlanke Geschäftssoftware für Solo-Selbstständige und kleine Teams.</text>
<text x="80" y="585" font-family="JetBrains Mono, monospace" font-weight="500" font-size="14" fill="#F5F5F0" opacity="0.55" letter-spacing="1.5">SLIMCORE.IO · IN DEUTSCHLAND GEHOSTET · OPEN SOURCE</text>
</svg>
`;
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
writeFileSync('public/og-default.png', buffer);
console.log('✓ public/og-default.png generated (1200×630)');

View file

@ -0,0 +1,144 @@
import { useMemo, useState } from 'react';
import type { Lang, Module, ModuleStatus, Pillar } from '@/content/module';
const ALL = 'all' as const;
type StatusKey = typeof ALL | ModuleStatus;
type PillarKey = typeof ALL | Pillar;
interface FilterLabels {
filterStatusHeading: string;
filterPillarHeading: string;
allLabel: string;
statusLabels: Record<ModuleStatus, string>;
pillarTitles: Record<Pillar, string>;
/** Wird mit dem Suffix (Module/modules) zusammengesetzt */
resultsSingular: string;
resultsPlural: string;
}
interface SerializedModule {
id: string;
name: string;
pillar: Pillar;
pillarTitle: string;
status: ModuleStatus;
description: string;
}
interface Props {
modules: SerializedModule[];
labels: FilterLabels;
lang: Lang;
}
const STATUS_ORDER: StatusKey[] = [ALL, 'available', 'developing', 'planned', 'vision'];
const PILLAR_ORDER: PillarKey[] = [ALL, '01', '02', '03', '04'];
function statusDotClass(status: ModuleStatus): string {
return `dot dot-${status}`;
}
export default function ModuleFilter({ modules, labels }: Props) {
const [status, setStatus] = useState<StatusKey>(ALL);
const [pillar, setPillar] = useState<PillarKey>(ALL);
const filtered = useMemo(() => {
return modules.filter((m) => {
if (status !== ALL && m.status !== status) return false;
if (pillar !== ALL && m.pillar !== pillar) return false;
return true;
});
}, [modules, status, pillar]);
const isActive = (current: string, value: string) => current === value;
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-6 border-b border-[var(--color-border)] pb-8">
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6">
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{labels.filterStatusHeading}
</span>
<div className="flex flex-wrap gap-2">
{STATUS_ORDER.map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
aria-pressed={isActive(status, s)}
className={
isActive(status, s)
? 'inline-flex items-center gap-2 rounded-full bg-[var(--color-text-primary)] px-3 py-1 text-[12px] font-medium text-[var(--color-bg-base)]'
: 'inline-flex items-center gap-2 rounded-full border border-[var(--color-border-strong)] bg-transparent px-3 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-text-primary)] hover:text-[var(--color-text-primary)]'
}
>
{s !== ALL && <span aria-hidden="true" className={statusDotClass(s)} />}
{s === ALL ? labels.allLabel : labels.statusLabels[s]}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6">
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{labels.filterPillarHeading}
</span>
<div className="flex flex-wrap gap-2">
{PILLAR_ORDER.map((p) => (
<button
key={p}
type="button"
onClick={() => setPillar(p)}
aria-pressed={isActive(pillar, p)}
className={
isActive(pillar, p)
? 'inline-flex items-center gap-2 rounded-full bg-[var(--color-text-primary)] px-3 py-1 text-[12px] font-medium text-[var(--color-bg-base)]'
: 'inline-flex items-center gap-2 rounded-full border border-[var(--color-border-strong)] bg-transparent px-3 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-text-primary)] hover:text-[var(--color-text-primary)]'
}
>
{p === ALL ? labels.allLabel : `${p} · ${labels.pillarTitles[p]}`}
</button>
))}
</div>
</div>
</div>
<p className="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]" aria-live="polite">
{filtered.length} {filtered.length === 1 ? labels.resultsSingular : labels.resultsPlural}
</p>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2">
{filtered.map((m) => (
<li
key={m.id}
className="flex flex-col gap-3 border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5"
>
<div className="flex items-baseline justify-between gap-3">
<div>
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
{m.pillar} · {m.pillarTitle}
</span>
<h3 className="mt-1 font-serif text-[1.25rem] font-medium leading-[1.3] text-[var(--color-text-primary)]">
{m.name}
</h3>
</div>
<span className="flex items-center gap-2">
<span aria-hidden="true" className={statusDotClass(m.status)} />
<span className="font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-secondary)]">
{labels.statusLabels[m.status]}
</span>
</span>
</div>
<p className="text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
{m.description}
</p>
</li>
))}
</ul>
{filtered.length === 0 && (
<p className="text-[var(--color-text-secondary)]"></p>
)}
</div>
);
}

View file

@ -0,0 +1,70 @@
---
interface CTA {
label: string;
href: string;
variant?: 'primary' | 'secondary' | 'ghost';
}
interface Props {
/** Eyebrow-Text (optional) */
eyebrow?: string;
/** Headline (Serif) */
headline: string;
/** Body-Text (optional) */
body?: string;
/** Bis zu 3 CTAs */
ctas: CTA[];
/** Hellen oder dunklen Modus erzwingen. Default: hell */
inverse?: boolean;
class?: string;
}
const { eyebrow, headline, body, ctas, inverse = false, class: className = '' } = Astro.props;
const headlineColor = inverse ? 'text-[#F5F5F0]' : 'text-[var(--color-text-primary)]';
const eyebrowColor = inverse ? 'text-[rgba(245,245,240,0.85)]' : 'text-[var(--color-text-tertiary)]';
const bodyColor = inverse ? 'text-[rgba(245,245,240,0.75)]' : 'text-[var(--color-text-secondary)]';
const ctaClass = (variant: CTA['variant']) => {
if (variant === 'ghost') {
return inverse
? 'underline underline-offset-4 text-[#F5F5F0] hover:text-[var(--color-accent)]'
: 'underline underline-offset-4 text-[var(--color-text-primary)] hover:text-[var(--color-accent)]';
}
if (variant === 'secondary') {
return inverse
? 'rounded-md border-[0.5px] border-[rgba(245,245,240,0.5)] bg-transparent px-5 py-[11px] text-[13px] font-medium text-[#F5F5F0] hover:bg-[rgba(245,245,240,0.06)]'
: 'rounded-md border border-[var(--color-text-primary)] bg-transparent px-5 py-[11px] text-[13px] font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-text-primary)]/5';
}
// primary
return inverse
? 'rounded-md bg-[var(--color-accent)] px-5 py-[11px] text-[13px] font-medium text-[var(--color-text-on-accent)] hover:bg-[var(--color-accent-hover)]'
: 'rounded-md bg-[var(--color-text-primary)] px-5 py-[11px] text-[13px] font-medium text-[var(--color-bg-base)] hover:bg-[var(--color-accent)] hover:text-[var(--color-text-on-accent)]';
};
---
<section class:list={['flex flex-col gap-6', className]}>
{eyebrow && (
<p class:list={['font-mono text-[11px] font-medium uppercase tracking-[0.08em]', eyebrowColor]}>
{eyebrow}
</p>
)}
<h2 class:list={['max-w-[20ch] font-serif text-[2rem] md:text-[2.5rem] font-medium leading-[1.15] tracking-[-0.005em]', headlineColor]}>
{headline}
</h2>
{body && (
<p class:list={['max-w-[60ch] text-[1.0625rem] leading-relaxed', bodyColor]}>
{body}
</p>
)}
<div class="flex flex-wrap items-center gap-3">
{ctas.map((cta) => (
<a
href={cta.href}
class:list={['inline-flex items-center gap-1 transition-colors', ctaClass(cta.variant ?? 'primary')]}
>
{cta.label} {cta.variant !== 'ghost' && <span aria-hidden="true">→</span>}
</a>
))}
</div>
</section>

View file

@ -0,0 +1,33 @@
---
interface Props {
/** Status-Pille rechts neben dem Text, z.B. "IN ENTWICKLUNG" */
status?: string;
/** Vorangestelltes Glyph, z.B. "▸" für den Hero. Default: keines */
prefix?: string;
/** Farbe des Texts. Default: tertiäre Textfarbe (helle Sektionen) */
tone?: 'tertiary' | 'accent' | 'inverse';
class?: string;
}
const { status, prefix, tone = 'tertiary', class: className = '' } = Astro.props;
const toneClass = {
tertiary: 'text-[var(--color-text-tertiary)]',
accent: 'text-[var(--color-accent)]',
inverse: 'text-[rgba(245,245,240,0.85)]',
}[tone];
---
<p class:list={[
'font-mono text-[11px] font-medium uppercase tracking-[0.08em] leading-[1.2]',
toneClass,
className,
]}>
{prefix && <span aria-hidden="true">{prefix}&nbsp;</span>}
<slot />
{status && (
<span class="ml-2 inline-flex items-center border border-current px-1.5 py-0.5 text-[10px] tracking-[0.06em]">
{status}
</span>
)}
</p>

View file

@ -0,0 +1,75 @@
---
import { t } from '@/i18n/strings';
import type { Lang } from '@/content/module';
interface Props {
lang: Lang;
}
const { lang } = Astro.props;
const s = t(lang);
const currentYear = new Date().getFullYear();
const imprintHref = lang === 'en' ? '/en/imprint' : '/impressum';
const privacyHref = lang === 'en' ? '/en/privacy' : '/datenschutz';
---
<footer class="border-t border-[var(--color-border)] bg-[var(--color-bg-base)]">
<div class="mx-auto grid max-w-[1100px] gap-10 px-6 py-16 md:grid-cols-4 md:px-10 xl:px-12">
<div>
<span class="font-mono text-sm font-medium text-[var(--color-text-primary)]">SlimCore</span>
<p class="mt-3 text-sm text-[var(--color-text-secondary)]">
{s.footer.tagline}
</p>
</div>
<div>
<h3 class="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{s.footer.productHeader}
</h3>
<ul class="mt-4 space-y-2.5">
{s.footer.productLinks.map((link) => (
<li>
<a href={link.href} class="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-accent)]">
{link.label}
</a>
</li>
))}
</ul>
</div>
<div>
<h3 class="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{s.footer.stackHeader}
</h3>
<ul class="mt-4 space-y-2.5">
<li><span class="font-mono text-sm text-[var(--color-text-secondary)]">PostgreSQL</span></li>
<li><span class="font-mono text-sm text-[var(--color-text-secondary)]">PostgREST</span></li>
<li><span class="font-mono text-sm text-[var(--color-text-secondary)]">Docker</span></li>
<li><span class="font-mono text-sm text-[var(--color-text-secondary)]">Hetzner DE</span></li>
</ul>
</div>
<div>
<h3 class="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{s.footer.contactHeader}
</h3>
<ul class="mt-4 space-y-2.5">
<li><a href="mailto:hallo@slimcore.io" class="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-accent)]">hallo@slimcore.io</a></li>
<li><a href="https://calendly.com/digiformer/quick-call" target="_blank" rel="noopener noreferrer" class="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-accent)]">{s.footer.bookCall}</a></li>
<li><span class="text-sm text-[var(--color-text-tertiary)]">digiFORMER GmbH</span></li>
</ul>
</div>
</div>
<div class="border-t border-[var(--color-border)]">
<div class="mx-auto flex max-w-[1100px] flex-wrap items-center justify-between gap-4 px-6 py-5 md:px-10 xl:px-12">
<p class="text-xs text-[var(--color-text-tertiary)]">
&copy; {currentYear} SlimCore &mdash; {s.footer.productOf} <a href="https://digiformer.eu" class="hover:text-[var(--color-accent)]">digiFORMER GmbH</a>
</p>
<nav class="flex gap-4 text-xs text-[var(--color-text-tertiary)]">
<a href={imprintHref} class="hover:text-[var(--color-accent)]">{s.footer.imprint}</a>
<a href={privacyHref} class="hover:text-[var(--color-accent)]">{s.footer.privacy}</a>
</nav>
</div>
</div>
</footer>

View file

@ -0,0 +1,37 @@
---
import StatusDot from './StatusDot.astro';
import type { Module, Pillar, Lang } from '@/content/module';
interface Props {
pillarNumber: Pillar;
pillarTitle: string;
modules: Pick<Module, 'name' | 'status'>[];
lang?: Lang;
class?: string;
}
const { pillarNumber, pillarTitle, modules, lang = 'de', class: className = '' } = Astro.props;
---
<article class:list={[
'flex flex-col gap-4 border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5',
className,
]}>
<header class="flex flex-col gap-1">
<span class="font-mono text-[0.8125rem] font-medium tracking-[0.04em] text-[var(--color-text-tertiary)]">
{pillarNumber}
</span>
<h3 class="font-serif text-[1.125rem] font-medium leading-[1.3] text-[var(--color-text-primary)]">
{pillarTitle}
</h3>
</header>
<ul class="flex flex-col gap-2.5">
{modules.map((m) => (
<li class="flex items-center justify-between gap-3 text-[0.9375rem]">
<span class="text-[var(--color-text-primary)]">{m.name}</span>
<StatusDot status={m.status} lang={lang} />
</li>
))}
</ul>
</article>

View file

@ -0,0 +1,47 @@
---
import ModuleCard from './ModuleCard.astro';
import StatusDot from './StatusDot.astro';
import { modules, pillarTitles, pillarTitlesEn, getModuleName, getStatusLabel } from '@/content/module';
import type { Pillar, ModuleStatus, Lang } from '@/content/module';
interface Props {
/** Legende mit allen 4 Status-Glyphen anzeigen */
legend?: boolean;
lang?: Lang;
class?: string;
}
const { legend = true, lang = 'de', class: className = '' } = Astro.props;
const pillars: Pillar[] = ['01', '02', '03', '04'];
const allStatuses: ModuleStatus[] = ['available', 'developing', 'planned', 'vision'];
const titles = lang === 'en' ? pillarTitlesEn : pillarTitles;
---
<div class:list={['flex flex-col gap-8', className]}>
{legend && (
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-[var(--color-border)] pb-5">
{allStatuses.map((s) => (
<span class="flex items-center gap-2">
<StatusDot status={s} lang={lang} />
<span class="font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-secondary)]">
{getStatusLabel(s, lang)}
</span>
</span>
))}
</div>
)}
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 md:gap-4 lg:grid-cols-4">
{pillars.map((p) => (
<ModuleCard
pillarNumber={p}
pillarTitle={titles[p]}
lang={lang}
modules={modules
.filter((m) => m.pillar === p)
.map((m) => ({ name: getModuleName(m, lang), status: m.status }))}
/>
))}
</div>
</div>

View file

@ -0,0 +1,183 @@
---
import { t, getLocalizedPath } from '@/i18n/strings';
import type { Lang } from '@/content/module';
interface Props {
lang: Lang;
}
const { lang } = Astro.props;
const s = t(lang);
const navLinks =
lang === 'en'
? [
{ href: '/en/module', label: s.nav.modules },
{ href: '/en/sovereignty', label: s.nav.sovereignty },
{ href: '/en/roadmap', label: s.nav.roadmap },
{ href: '/en/tester', label: s.nav.testers },
]
: [
{ href: '/module', label: s.nav.modules },
{ href: '/souveraenitaet', label: s.nav.sovereignty },
{ href: '/roadmap', label: s.nav.roadmap },
{ href: '/tester', label: s.nav.testers },
];
const dePath = getLocalizedPath(Astro.url.pathname, 'de');
const enPath = getLocalizedPath(Astro.url.pathname, 'en');
const testerHref = lang === 'en' ? '/en/tester' : '/tester';
---
<header
id="nav-bar"
class="nav-bar nav-bar--dark sticky top-0 z-40 transition-colors duration-200"
data-mode="dark"
>
<nav class="mx-auto flex max-w-[1100px] items-center justify-between px-6 py-4 md:px-10 xl:px-12">
<a href={lang === 'en' ? '/en/' : '/'} class="nav-brand font-mono text-base font-medium tracking-tight">
SlimCore
</a>
<ul class="hidden items-center gap-8 md:flex">
{navLinks.map((link) => (
<li>
<a href={link.href} class="nav-link text-sm transition-colors">
{link.label}
</a>
</li>
))}
</ul>
<div class="hidden items-center gap-4 md:flex">
<span class="nav-lang font-mono text-xs">
<a
href={dePath}
class:list={['nav-lang-link', lang === 'de' ? 'nav-lang-active' : 'nav-lang-inactive']}
aria-current={lang === 'de' ? 'true' : undefined}
>DE</a>
<span class="nav-lang-divider"> | </span>
<a
href={enPath}
class:list={['nav-lang-link', lang === 'en' ? 'nav-lang-active' : 'nav-lang-inactive']}
aria-current={lang === 'en' ? 'true' : undefined}
>EN</a>
</span>
<a href={testerHref} class="nav-cta inline-flex items-center gap-1 rounded-md px-4 py-2 text-sm font-medium transition-colors">
{s.nav.becomeTester} <span aria-hidden="true">→</span>
</a>
</div>
<button class="md:hidden p-2 nav-link" aria-label={s.nav.openMenu} id="mobile-menu-toggle">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</nav>
</header>
<style>
/* — Dark Mode (über dunklem Hero) — */
.nav-bar--dark {
background-color: #0e0f14;
border-bottom: 0.5px solid transparent;
}
.nav-bar--dark .nav-brand {
color: #f5f5f0;
}
.nav-bar--dark .nav-link {
color: rgba(245, 245, 240, 0.7);
}
.nav-bar--dark .nav-link:hover {
color: #f5f5f0;
}
.nav-bar--dark .nav-lang-active {
color: #f5f5f0;
}
.nav-bar--dark .nav-lang-inactive {
color: rgba(245, 245, 240, 0.6);
}
.nav-bar--dark .nav-lang-inactive:hover {
color: rgba(245, 245, 240, 0.7);
}
.nav-bar--dark .nav-lang-divider {
color: rgba(245, 245, 240, 0.3);
}
.nav-bar--dark .nav-cta {
background-color: #ff6b2c;
color: #2a0f02;
}
.nav-bar--dark .nav-cta:hover {
background-color: var(--color-accent-hover);
}
/* — Light Mode (auf hellem Body) — */
.nav-bar--light {
background-color: var(--color-bg-base);
border-bottom: 0.5px solid var(--color-border);
}
.nav-bar--light .nav-brand {
color: var(--color-text-primary);
}
.nav-bar--light .nav-link {
color: var(--color-text-secondary);
}
.nav-bar--light .nav-link:hover {
color: var(--color-text-primary);
}
.nav-bar--light .nav-lang-active {
color: var(--color-text-primary);
}
.nav-bar--light .nav-lang-inactive {
color: var(--color-text-secondary);
}
.nav-bar--light .nav-lang-inactive:hover {
color: var(--color-text-primary);
opacity: 1;
}
.nav-bar--light .nav-lang-divider {
color: var(--color-text-tertiary);
opacity: 0.5;
}
.nav-bar--light .nav-cta {
background-color: var(--color-text-primary);
color: var(--color-bg-base);
}
.nav-bar--light .nav-cta:hover {
background-color: var(--color-accent);
color: var(--color-text-on-accent);
}
.nav-lang-link {
transition: color 150ms ease;
}
</style>
<script>
const navBar = document.getElementById('nav-bar');
const heroEnd = document.querySelector('[data-hero-end]');
function setMode(mode: 'dark' | 'light') {
if (!navBar) return;
navBar.classList.remove('nav-bar--dark', 'nav-bar--light');
navBar.classList.add(`nav-bar--${mode}`);
navBar.dataset.mode = mode;
}
if (heroEnd && navBar) {
// Auf Seiten mit dunklem Hero: NavBar wechselt zu hell, sobald Sentinel an die NavBar-Unterkante stößt
const update = () => {
const sentinelTop = heroEnd.getBoundingClientRect().top;
const navHeight = navBar.offsetHeight;
setMode(sentinelTop <= navHeight ? 'light' : 'dark');
};
update();
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update, { passive: true });
} else if (navBar) {
// Andere Seiten: immer Light-Mode
setMode('light');
}
</script>

View file

@ -0,0 +1,27 @@
---
interface Props {
/** Nummer als Mono-String, z.B. "01" */
number: string;
title: string;
inverse?: boolean;
class?: string;
}
const { number, title, inverse = false, class: className = '' } = Astro.props;
const numberColor = inverse ? 'text-[rgba(245,245,240,0.55)]' : 'text-[var(--color-text-tertiary)]';
const titleColor = inverse ? 'text-[#F5F5F0]' : 'text-[var(--color-text-primary)]';
const bodyColor = inverse ? 'text-[rgba(245,245,240,0.75)]' : 'text-[var(--color-text-secondary)]';
---
<div class:list={['flex flex-col gap-2', className]}>
<span class:list={['font-mono text-[0.8125rem] font-medium tracking-[0.04em]', numberColor]}>
{number}
</span>
<h3 class:list={['font-serif text-[1.25rem] font-medium leading-[1.3]', titleColor]}>
{title}
</h3>
<div class:list={['text-[1rem] leading-[1.65]', bodyColor]}>
<slot />
</div>
</div>

View file

@ -0,0 +1,17 @@
---
interface Props {
question: string;
class?: string;
}
const { question, class: className = '' } = Astro.props;
---
<article class:list={['flex flex-col gap-3 border-l-2 border-[var(--color-border-strong)] pl-5', className]}>
<blockquote class="font-serif text-[1.125rem] font-medium leading-[1.4] text-[var(--color-text-primary)]">
„{question}"
</blockquote>
<p class="text-[1rem] leading-[1.65] text-[var(--color-text-secondary)]">
<slot />
</p>
</article>

View file

@ -0,0 +1,66 @@
---
import StatusDot from './StatusDot.astro';
import type { ModuleStatus, Lang } from '@/content/module';
import { t } from '@/i18n/strings';
export interface RoadmapItem {
name: string;
status: ModuleStatus;
}
export interface RoadmapPhase {
/** z.B. „HEUTE", „Q3Q4 2026", „2027", „VISION" */
label: string;
/** Untertitel pro Phase */
description?: string;
/** Modul-/Item-Liste */
items: RoadmapItem[];
/** Markiert die aktuelle Phase mit Persimmon-Pille */
current?: boolean;
}
interface Props {
phases: RoadmapPhase[];
lang?: Lang;
class?: string;
}
const { phases, lang = 'de', class: className = '' } = Astro.props;
const s = t(lang);
---
<ol class:list={['relative flex flex-col gap-10 border-l border-[var(--color-border)] pl-8', className]}>
{phases.map((phase) => (
<li class="relative">
{/* Marker auf der Timeline-Linie */}
<span
class="absolute left-[-37px] top-[6px] h-3 w-3 rounded-full bg-[var(--color-bg-base)] outline outline-2"
style={`outline-color: ${phase.current ? 'var(--color-accent)' : 'var(--color-border-strong)'};`}
aria-hidden="true"
/>
<div class="flex flex-wrap items-baseline gap-3">
<span class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{phase.label}
</span>
{phase.current && (
<span class="bg-[var(--color-accent)] px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-on-accent)]">
{s.roadmap.currentPill}
</span>
)}
</div>
{phase.description && (
<p class="mt-2 max-w-[60ch] text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
{phase.description}
</p>
)}
<ul class="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2">
{phase.items.map((item) => (
<li class="flex items-center gap-3 text-[0.9375rem] text-[var(--color-text-primary)]">
<StatusDot status={item.status} lang={lang} />
<span>{item.name}</span>
</li>
))}
</ul>
</li>
))}
</ol>

View file

@ -0,0 +1,56 @@
---
import Eyebrow from './Eyebrow.astro';
interface Props {
/** Eyebrow-Text (Mono, uppercase) */
eyebrow?: string;
/** Vorangestelltes Glyph im Eyebrow, z.B. „▸" */
eyebrowPrefix?: string;
/** Eyebrow-Tone-Override. Default: accent bei inverse, tertiary sonst */
eyebrowTone?: 'tertiary' | 'accent' | 'inverse';
/** Subtitle/Lead unter der Headline */
subtitle?: string;
/** HTML-Tag-Level. Default h2 — nur Hero nutzt h1 inline. */
as?: 'h1' | 'h2' | 'h3';
/** Display-Variante (größere Headline für „große Versprechen"-Sektionen) */
display?: boolean;
/** Inverse-Variante für dunkle Hintergründe */
inverse?: boolean;
class?: string;
}
const {
eyebrow,
eyebrowPrefix,
eyebrowTone,
subtitle,
as: Tag = 'h2',
display = false,
inverse = false,
class: className = '',
} = Astro.props;
const headingSize = display
? 'text-[2rem] md:text-[3rem]'
: 'text-[1.5rem] md:text-[1.875rem]';
const textColor = inverse ? 'text-[#F5F5F0]' : 'text-[var(--color-text-primary)]';
const subtitleColor = inverse ? 'text-[rgba(245,245,240,0.75)]' : 'text-[var(--color-text-secondary)]';
const resolvedEyebrowTone = eyebrowTone ?? (inverse ? 'accent' : 'tertiary');
---
<header class:list={[className]}>
{eyebrow && <Eyebrow tone={resolvedEyebrowTone} prefix={eyebrowPrefix}>{eyebrow}</Eyebrow>}
<Tag class:list={[
'font-serif font-medium leading-[1.2] tracking-[-0.005em]',
headingSize,
textColor,
eyebrow ? 'mt-3' : '',
]}>
<slot />
</Tag>
{subtitle && (
<p class:list={['mt-4 max-w-[65ch] text-[1.0625rem] leading-relaxed', subtitleColor]}>
{subtitle}
</p>
)}
</header>

View file

@ -0,0 +1,50 @@
---
interface Item {
/** Mono-Caption (z.B. „01 · HOSTING") */
label: string;
/** Body-Text */
body: string;
}
interface Props {
/** Headline links — als Array von Zeilen, da der Spec mehrzeilige Display-Headlines vorsieht */
headlineLines: string[];
/** Lead-Text rechts oben */
lead: string;
/** 4 Argumente im 2×2-Grid */
items: Item[];
class?: string;
}
const { headlineLines, lead, items, class: className = '' } = Astro.props;
---
<section class:list={['grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-16', className]}>
<div class="md:col-span-5">
<h2 class="font-serif text-[2rem] md:text-[3rem] font-medium leading-[1.05] tracking-[-0.015em] text-[var(--color-text-primary)]">
{headlineLines.map((line, i) => (
<>
{line}{i < headlineLines.length - 1 && <br />}
</>
))}
</h2>
</div>
<div class="flex flex-col gap-8 md:col-span-7">
<p class="max-w-[60ch] text-[1.0625rem] leading-relaxed text-[var(--color-text-secondary)]">
{lead}
</p>
<div class="grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
{items.map((item) => (
<div class="border-l-2 border-[var(--color-accent)] pl-4">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{item.label}
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-primary)]">
{item.body}
</p>
</div>
))}
</div>
</div>
</section>

View file

@ -0,0 +1,63 @@
---
import type { ModuleStatus, Lang } from '@/content/module';
import { getStatusLabel } from '@/content/module';
interface Props {
status: ModuleStatus;
/** Inverse-Variante für dunkle Hintergründe */
inverse?: boolean;
/** Beschriftung anzeigen (Mono, klein). Default: false (nur Glyph) */
label?: boolean;
lang?: Lang;
class?: string;
}
const { status, inverse = false, label = false, lang = 'de', class: className = '' } = Astro.props;
const text = getStatusLabel(status, lang);
---
<span class:list={['status-dot inline-flex items-center gap-2', className]} data-status={status} data-inverse={inverse ? 'true' : 'false'}>
<span aria-hidden="true" class="dot" />
{label && <span class="font-mono text-[11px] uppercase tracking-[0.06em] opacity-80">{text}</span>}
<span class="sr-only">{text}</span>
</span>
<style>
.status-dot .dot {
--dot-color: var(--color-text-primary);
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
}
.status-dot[data-inverse='true'] .dot {
--dot-color: #f5f5f0;
}
/* Verfügbar — gefüllter Kreis */
.status-dot[data-status='available'] .dot {
background-color: var(--dot-color);
}
/* In Entwicklung — halbgefüllter Kreis (Filled mit 0.45 Opacity) */
.status-dot[data-status='developing'] .dot {
background-color: var(--dot-color);
opacity: 0.45;
}
/* Geplant — solider Outline-Kreis */
.status-dot[data-status='planned'] .dot {
background: transparent;
border: 1.5px solid var(--dot-color);
opacity: 0.7;
}
/* Vision — gestrichelter Outline-Kreis */
.status-dot[data-status='vision'] .dot {
background: transparent;
border: 1.5px dashed var(--dot-color);
opacity: 0.6;
}
</style>

View file

@ -0,0 +1,40 @@
---
interface Props {
/** Label vorn, default „STACK". Heller dargestellt als Items */
label?: string;
/** Stack-Komponenten als Mono-Strings */
items: string[];
/** Hellen Modus für Light-Sektionen erzwingen. Default: dark */
variant?: 'dark' | 'light';
class?: string;
}
const { label = 'STACK', items, variant = 'dark', class: className = '' } = Astro.props;
const isDark = variant === 'dark';
---
<section
class:list={[
'tech-strip',
isDark ? 'bg-[#0E0F14]' : 'bg-[var(--color-bg-base)]',
className,
]}
style={isDark
? 'border-top: 0.5px solid rgba(245,245,240,0.08); border-bottom: 0.5px solid rgba(245,245,240,0.08);'
: 'border-top: 0.5px solid var(--color-border); border-bottom: 0.5px solid var(--color-border);'}
>
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 py-3 font-mono text-[11px] uppercase tracking-[0.06em]">
<span class:list={[isDark ? 'text-[rgba(245,245,240,0.85)]' : 'text-[var(--color-text-primary)]']}>
{label}
</span>
{items.map((item) => (
<>
<span class:list={[isDark ? 'text-[rgba(245,245,240,0.55)]' : 'text-[var(--color-text-tertiary)]']}>·</span>
<span class:list={[isDark ? 'text-[rgba(245,245,240,0.55)]' : 'text-[var(--color-text-secondary)]']}>{item}</span>
</>
))}
</div>
</div>
</section>

View file

@ -0,0 +1,51 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-[var(--color-text-primary)] text-[var(--color-bg-base)] hover:bg-[var(--color-accent)] hover:text-[var(--color-text-on-accent)] rounded-md",
secondary:
"border border-[var(--color-text-primary)] bg-transparent hover:bg-[var(--color-text-primary)]/5 rounded-md",
ghost:
"underline underline-offset-4 hover:text-[var(--color-accent)]",
},
size: {
default: "px-5 py-2.5",
sm: "px-3 py-1.5 text-xs",
lg: "px-6 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View file

@ -0,0 +1,78 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-medium font-serif", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-[var(--color-text-secondary)]", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
};

View file

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View file

@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none text-[var(--color-text-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View file

@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };

88
src/content/module.ts Normal file
View file

@ -0,0 +1,88 @@
export type ModuleStatus = 'available' | 'developing' | 'planned' | 'vision';
export type Pillar = '01' | '02' | '03' | '04';
export type Lang = 'de' | 'en';
export interface Module {
id: string;
name: string;
nameEn: string;
pillar: Pillar;
pillarTitle: string;
pillarTitleEn: string;
status: ModuleStatus;
description: string;
descriptionEn: string;
}
export const statusLabel: Record<ModuleStatus, string> = {
available: 'Verfügbar',
developing: 'In Entwicklung',
planned: 'Geplant',
vision: 'Vision',
};
export const statusLabelEn: Record<ModuleStatus, string> = {
available: 'Available',
developing: 'In development',
planned: 'Planned',
vision: 'Vision',
};
export const pillarTitles: Record<Pillar, string> = {
'01': 'Kunden & Kommunikation',
'02': 'Handel & Logistik',
'03': 'Belege & Finanzen',
'04': 'Team & Organisation',
};
export const pillarTitlesEn: Record<Pillar, string> = {
'01': 'Customers & Communication',
'02': 'Commerce & Logistics',
'03': 'Documents & Finance',
'04': 'Team & Organization',
};
export function getStatusLabel(status: ModuleStatus, lang: Lang = 'de'): string {
return lang === 'en' ? statusLabelEn[status] : statusLabel[status];
}
export function getPillarTitle(pillar: Pillar, lang: Lang = 'de'): string {
return lang === 'en' ? pillarTitlesEn[pillar] : pillarTitles[pillar];
}
export function getModuleName(module: Module, lang: Lang = 'de'): string {
return lang === 'en' ? module.nameEn : module.name;
}
export const modules: readonly Module[] = [
// Pillar 01 — Kunden & Kommunikation
{ id: 'crm', name: 'CRM', nameEn: 'CRM', pillar: '01', pillarTitle: pillarTitles['01'], pillarTitleEn: pillarTitlesEn['01'], status: 'available', description: 'Zentrale Kontaktverwaltung mit Firmen/Personen, Interaktions-Timeline, Vertriebs-Pipeline (Kanban), Tags, Custom Fields, Massenaktionen.', descriptionEn: 'Central contact management with companies/people, interaction timeline, sales pipeline (Kanban), tags, custom fields, bulk actions.' },
{ id: 'team-email', name: 'Team-E-Mail', nameEn: 'Team email', pillar: '01', pillarTitle: pillarTitles['01'], pillarTitleEn: pillarTitlesEn['01'], status: 'developing', description: 'Geteilte Postfächer über MS365 angebunden. Drei-Spalten-UI, automatische CRM-Zuordnung, Inbox-Übersicht.', descriptionEn: 'Shared mailboxes via MS365. Three-column UI, automatic CRM matching, unified inbox.' },
{ id: 'helpdesk', name: 'Helpdesk', nameEn: 'Helpdesk', pillar: '01', pillarTitle: pillarTitles['01'], pillarTitleEn: pillarTitlesEn['01'], status: 'planned', description: 'Aus E-Mails werden Tickets. Zuweisung, Status-Workflow, SLA-Tracking. Kein Portal-Konto nötig.', descriptionEn: 'Emails become tickets. Assignment, status workflow, SLA tracking. No portal account required.' },
{ id: 'phone', name: 'Telefonie', nameEn: 'Telephony', pillar: '01', pillarTitle: pillarTitles['01'], pillarTitleEn: pillarTitlesEn['01'], status: 'planned', description: 'Yeastar P520-Anbindung. Echtzeit-Erfassung, CRM-Zuordnung, verpasste Anrufe als Widget.', descriptionEn: 'Yeastar P520 integration. Real-time capture, CRM matching, missed calls as a widget.' },
{ id: 'whatsapp', name: 'WhatsApp', nameEn: 'WhatsApp', pillar: '01', pillarTitle: pillarTitles['01'], pillarTitleEn: pillarTitlesEn['01'], status: 'planned', description: 'WhatsApp Business API als Eingangskanal. Automatisches CRM-Matching, Timeline-Eintrag.', descriptionEn: 'WhatsApp Business API as inbound channel. Automatic CRM matching, timeline entry.' },
// Pillar 02 — Handel & Logistik
{ id: 'items', name: 'Artikel', nameEn: 'Items', pillar: '02', pillarTitle: pillarTitles['02'], pillarTitleEn: pillarTitlesEn['02'], status: 'developing', description: 'Stammdaten mit Varianten, Stücklisten/Bundles, Chargen-/MHD-/Seriennummern, Preislisten, EU-LMIV-Felder.', descriptionEn: 'Master data with variants, BOMs/bundles, batch/best-before/serial numbers, price lists, EU FIC fields.' },
{ id: 'inventory', name: 'Lager', nameEn: 'Inventory', pillar: '02', pillarTitle: pillarTitles['02'], pillarTitleEn: pillarTitlesEn['02'], status: 'available', description: 'Lagerorte, Bestandsführung, Wareneingänge, FIFO/FEFO, MHD-Tracking, Seriennummern, mehrere Lager.', descriptionEn: 'Locations, stock management, goods receipt, FIFO/FEFO, best-before tracking, serial numbers, multi-warehouse.' },
{ id: 'orders', name: 'Bestellungen', nameEn: 'Orders', pillar: '02', pillarTitle: pillarTitles['02'], pillarTitleEn: pillarTitlesEn['02'], status: 'available', description: 'Alle Kanäle konsolidiert. Positionsverwaltung, Status-Workflow, Bezahlstatus-Tracking.', descriptionEn: 'All channels consolidated. Line item management, status workflow, payment status tracking.' },
{ id: 'channels', name: 'Verkaufskanäle', nameEn: 'Sales channels', pillar: '02', pillarTitle: pillarTitles['02'], pillarTitleEn: pillarTitlesEn['02'], status: 'developing', description: 'WooCommerce bidirektional, Amazon SP-API kurz davor, Shopify/Kaufland/Etsy später.', descriptionEn: 'WooCommerce bidirectional, Amazon SP-API close to ready, Shopify/Kaufland/Etsy later.' },
{ id: 'shipping', name: 'Versand', nameEn: 'Shipping', pillar: '02', pillarTitle: pillarTitles['02'], pillarTitleEn: pillarTitlesEn['02'], status: 'developing', description: 'Multi-Carrier: DHL zuerst, DPD/GLS/Hermes folgen. Label, Tracking, Shiptastic-Integration.', descriptionEn: 'Multi-carrier: DHL first, DPD/GLS/Hermes to follow. Label printing, tracking, Shiptastic integration.' },
{ id: 'purchasing', name: 'Einkauf', nameEn: 'Purchasing', pillar: '02', pillarTitle: pillarTitles['02'], pillarTitleEn: pillarTitlesEn['02'], status: 'planned', description: 'Lieferanten als CRM-Kontakte, Einkaufsbestellungen, Wareneingang mit Bestandsbuchung.', descriptionEn: 'Suppliers as CRM contacts, purchase orders, goods receipt with stock posting.' },
// Pillar 03 — Belege & Finanzen
{ id: 'documents', name: 'Belege', nameEn: 'Documents', pillar: '03', pillarTitle: pillarTitles['03'], pillarTitleEn: pillarTitlesEn['03'], status: 'available', description: 'Konfigurierbarer Belegfluss: Angebot → AB → Lieferschein → Rechnung → Gutschrift → Mahnung. PDF-Erzeugung, Vorlagen pro Mandant.', descriptionEn: 'Configurable document flow: quote → order confirmation → delivery note → invoice → credit note → dunning. PDF generation, per-tenant templates.' },
{ id: 'invoices', name: 'Rechnungen', nameEn: 'Invoices', pillar: '03', pillarTitle: pillarTitles['03'], pillarTitleEn: pillarTitlesEn['03'], status: 'developing', description: 'Aus Bestellungen, Zeiterfassung oder manuell. ZUGFeRD 2.0 und XRechnung.', descriptionEn: 'From orders, time tracking, or manual. ZUGFeRD 2.0 and XRechnung.' },
{ id: 'payments', name: 'Zahlungen', nameEn: 'Payments', pillar: '03', pillarTitle: pillarTitles['03'], pillarTitleEn: pillarTitlesEn['03'], status: 'available', description: 'Offene-Posten, Zahlungseingänge, Teilzahlungen, vierstufiges Mahnwesen, Liquiditäts-Widget.', descriptionEn: 'Open items, incoming payments, partial payments, four-stage dunning, liquidity widget.' },
{ id: 'accounting', name: 'BuHa-Export', nameEn: 'Accounting export', pillar: '03', pillarTitle: pillarTitles['03'], pillarTitleEn: pillarTitlesEn['03'], status: 'available', description: 'DATEV-CSV produktiv, sevDesk-API und Lexware-API in Vorbereitung.', descriptionEn: 'DATEV CSV in production, sevDesk and Lexware APIs in preparation.' },
// Pillar 04 — Team & Organisation
{ id: 'tasks', name: 'Aufgaben', nameEn: 'Tasks', pillar: '04', pillarTitle: pillarTitles['04'], pillarTitleEn: pillarTitlesEn['04'], status: 'developing', description: 'Kanban oder Liste. Zuweisung, Fälligkeiten, Kommentare, Verknüpfung mit Kontakten/Bestellungen/Belegen.', descriptionEn: 'Kanban or list. Assignment, due dates, comments, linked to contacts/orders/documents.' },
{ id: 'projects', name: 'Projekte', nameEn: 'Projects', pillar: '04', pillarTitle: pillarTitles['04'], pillarTitleEn: pillarTitlesEn['04'], status: 'planned', description: 'Klammer um Aufgaben mit Meilensteinen und Budget-Tracking.', descriptionEn: 'Wrapper around tasks with milestones and budget tracking.' },
{ id: 'time', name: 'Zeiterfassung', nameEn: 'Time tracking', pillar: '04', pillarTitle: pillarTitles['04'], pillarTitleEn: pillarTitlesEn['04'], status: 'planned', description: 'Stempeluhr + Projektzeiten. Erfasste Zeiten werden auf Wunsch zu Rechnungspositionen.', descriptionEn: 'Time clock + project hours. Captured time becomes invoice line items on demand.' },
{ id: 'hr', name: 'Personal', nameEn: 'HR', pillar: '04', pillarTitle: pillarTitles['04'], pillarTitleEn: pillarTitlesEn['04'], status: 'planned', description: 'Stammdaten, Arbeitsverträge, Urlaubsanträge mit Genehmigungs-Workflow, Abwesenheitskalender.', descriptionEn: 'Master data, contracts, leave requests with approval workflow, absence calendar.' },
] as const;
export function modulesByPillar(pillar: Pillar): Module[] {
return modules.filter((m) => m.pillar === pillar);
}

146
src/i18n/strings.ts Normal file
View file

@ -0,0 +1,146 @@
import type { Lang } from '@/content/module';
/** Erkennt die Sprache aus dem Pfad. /en/* → 'en', sonst 'de'. */
export function getLangFromPath(pathname: string): Lang {
return pathname.startsWith('/en/') || pathname === '/en' ? 'en' : 'de';
}
/**
* Gibt den entsprechenden Pfad in der Ziel-Sprache zurück.
* / DE Home, /en/ EN Home
* /module DE, /en/module EN
*/
export function getLocalizedPath(currentPath: string, targetLang: Lang): string {
const currentLang = getLangFromPath(currentPath);
if (currentLang === targetLang) return currentPath;
// Strip vorhandenen /en/-Prefix
const stripped = currentPath.replace(/^\/en(\/|$)/, '/');
if (targetLang === 'en') {
if (stripped === '/') return '/en/';
return `/en${stripped}`;
}
// Ziel DE
return stripped === '' ? '/' : stripped;
}
interface StringSet {
nav: {
modules: string;
sovereignty: string;
roadmap: string;
testers: string;
becomeTester: string;
openMenu: string;
};
footer: {
tagline: string;
productHeader: string;
stackHeader: string;
contactHeader: string;
bookCall: string;
productOf: string;
imprint: string;
privacy: string;
productLinks: { label: string; href: string }[];
};
legend: {
legendTitle: string;
};
roadmap: {
currentPill: string;
};
filter: {
filterStatusHeading: string;
filterPillarHeading: string;
allLabel: string;
resultsSingular: string;
resultsPlural: string;
};
}
export const strings: Record<Lang, StringSet> = {
de: {
nav: {
modules: 'Module',
sovereignty: 'Souveränität',
roadmap: 'Roadmap',
testers: 'Tester',
becomeTester: 'Tester werden',
openMenu: 'Menü öffnen',
},
footer: {
tagline: 'Schlanke Geschäftssoftware für Solo-Selbstständige und kleine Teams.',
productHeader: 'Produkt',
stackHeader: 'Stack',
contactHeader: 'Kontakt',
bookCall: 'Termin vereinbaren →',
productOf: 'ein Produkt der',
imprint: 'Impressum',
privacy: 'Datenschutz',
productLinks: [
{ label: 'Module', href: '/module' },
{ label: 'Souveränität', href: '/souveraenitaet' },
{ label: 'Roadmap', href: '/roadmap' },
{ label: 'Tester-Programm', href: '/tester' },
],
},
legend: {
legendTitle: 'Status-Legende',
},
roadmap: {
currentPill: 'Aktuell',
},
filter: {
filterStatusHeading: 'Status',
filterPillarHeading: 'Säule',
allLabel: 'Alle',
resultsSingular: 'Modul',
resultsPlural: 'Module',
},
},
en: {
nav: {
modules: 'Modules',
sovereignty: 'Sovereignty',
roadmap: 'Roadmap',
testers: 'Testers',
becomeTester: 'Become a tester',
openMenu: 'Open menu',
},
footer: {
tagline: 'The lean business software for solopreneurs and small teams.',
productHeader: 'Product',
stackHeader: 'Stack',
contactHeader: 'Contact',
bookCall: 'Book a call →',
productOf: 'a product of',
imprint: 'Imprint',
privacy: 'Privacy',
productLinks: [
{ label: 'Modules', href: '/en/module' },
{ label: 'Sovereignty', href: '/en/sovereignty' },
{ label: 'Roadmap', href: '/en/roadmap' },
{ label: 'Tester program', href: '/en/tester' },
],
},
legend: {
legendTitle: 'Status legend',
},
roadmap: {
currentPill: 'Current',
},
filter: {
filterStatusHeading: 'Status',
filterPillarHeading: 'Pillar',
allLabel: 'All',
resultsSingular: 'module',
resultsPlural: 'modules',
},
},
};
export function t(lang: Lang): StringSet {
return strings[lang];
}

View file

@ -0,0 +1,66 @@
---
import NavBar from '@/components/marketing/NavBar.astro';
import Footer from '@/components/marketing/Footer.astro';
import { getLangFromPath, getLocalizedPath } from '@/i18n/strings';
import type { Lang } from '@/content/module';
import '@/styles/global.css';
interface Props {
title: string;
description?: string;
ogImage?: string;
/** Override; falls nicht gesetzt, wird aus dem Pfad ermittelt */
lang?: Lang;
}
const lang: Lang = Astro.props.lang ?? getLangFromPath(Astro.url.pathname);
const defaultDescription =
lang === 'en'
? 'Modular SaaS platform for CRM, documents, tasks and workflow. Start lean, grow without limits — hosted in Germany, open-source stack, no vendor lock-in.'
: 'Modulare SaaS-Plattform für CRM, Belege, Aufgaben und Workflow. Schlank starten, grenzenlos wachsen — in Deutschland gehostet, Open-Source-Stack, kein Vendor-Lock-in.';
const {
title,
description = defaultDescription,
ogImage = '/og-default.png',
} = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const dePath = getLocalizedPath(Astro.url.pathname, 'de');
const enPath = getLocalizedPath(Astro.url.pathname, 'en');
---
<!doctype html>
<html lang={lang === 'en' ? 'en' : 'de'}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} — SlimCore</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(ogImage, Astro.site)} />
<meta property="og:type" content="website" />
<meta property="og:locale" content={lang === 'en' ? 'en_US' : 'de_DE'} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hreflang="de" href={new URL(dePath, Astro.site)} />
<link rel="alternate" hreflang="en" href={new URL(enPath, Astro.site)} />
<link rel="alternate" hreflang="x-default" href={new URL(dePath, Astro.site)} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preload" href="/fonts/outfit-variable.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/jetbrains-mono-variable.woff2" as="font" type="font/woff2" crossorigin />
</head>
<body class="min-h-screen bg-[var(--color-bg-base)] text-[var(--color-text-primary)]">
<NavBar lang={lang} />
<main>
<slot />
</main>
<Footer lang={lang} />
</body>
</html>

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

144
src/pages/datenschutz.astro Normal file
View file

@ -0,0 +1,144 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
const sectionPad = 'py-16 md:py-20';
const container = 'mx-auto max-w-[820px] px-6 md:px-10 xl:px-12';
const processors = [
{ name: 'Hetzner Online GmbH', country: 'Deutschland', purpose: 'Hosting der Webseite und der Anwendung. Server in Falkenstein und Nürnberg.', basis: 'Auftragsverarbeitung gemäß Art. 28 DSGVO. Vertrag (AVV) liegt vor.' },
{ name: 'Brevo SAS (Sendinblue)', country: 'Frankreich', purpose: 'Versand von Transaktions-E-Mails (z.B. Anmeldebestätigungen).', basis: 'Auftragsverarbeitung gemäß Art. 28 DSGVO. Vertrag (AVV) liegt vor.' },
{ name: 'Calendly LLC', country: 'USA · Übergangs-Realität', purpose: 'Termin-Buchung über externen Link. Keine Einbettung auf slimcore.io. Datenverarbeitung beginnt erst nach Klick auf den Calendly-Link.', basis: 'Externer Link, eigene Calendly-Datenschutzerklärung gilt. Wird durch SlimCore-eigenes Termin-Modul ersetzt.' },
];
---
<BaseLayout
title="Datenschutzerklärung"
description="Wie wir mit personenbezogenen Daten auf slimcore.io umgehen — Auftragsverarbeiter, Rechtsgrundlagen, Ihre Rechte. Keine Analytics, keine Tracking-Cookies."
>
<section class="bg-[#0E0F14] pt-[112px] pb-12 md:pt-[140px] md:pb-16">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="DSGVO · ART. 13"
>
Datenschutzerklärung
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
<section class={sectionPad}>
<div class={container}>
<div class="flex flex-col gap-12 text-[1rem] leading-[1.7] text-[var(--color-text-secondary)]">
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">1. Verantwortlicher</h2>
<p class="mt-3">
Verantwortlich im Sinne der DSGVO ist:
</p>
<address class="mt-3 not-italic">
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning<br />
Deutschland<br />
E-Mail: <a href="mailto:hallo@slimcore.io" class="underline underline-offset-2 hover:text-[var(--color-accent)]">hallo@slimcore.io</a>
</address>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">2. Daten beim Besuch der Webseite</h2>
<p class="mt-3">
Beim Aufruf von slimcore.io werden technisch notwendige Daten an den Webserver übertragen — IP-Adresse, User-Agent, Zeitstempel, angeforderte URL, Referrer. Diese Daten werden ausschließlich zur Auslieferung der Seite und zur Abwehr von technischen Angriffen verarbeitet (Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse). Sie werden nicht zu Analyse-Zwecken ausgewertet.
</p>
<p class="mt-3">
Server-Logs werden nach 14 Tagen gelöscht.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">3. Cookies</h2>
<p class="mt-3">
slimcore.io setzt <strong>keine Cookies</strong>. Es gibt keine Tracking-Cookies, keine Werbe-Cookies, keine Drittanbieter-Cookies. Daher entfällt eine Cookie-Banner-Pflicht.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">4. Analytics</h2>
<p class="mt-3">
Wir nutzen <strong>keine Web-Analytics</strong>. Kein Google Analytics, kein Plausible, kein Pirsch, kein Matomo. Wir wissen nicht, wer wie lange auf welcher Seite war.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">5. Schriften</h2>
<p class="mt-3">
Schriftarten (Outfit, JetBrains Mono) werden direkt von slimcore.io ausgeliefert (self-hosted). Es findet keine Verbindung zu Google Fonts oder anderen externen Schrift-CDNs statt.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">6. Kontakt per E-Mail</h2>
<p class="mt-3">
Wenn Sie uns per E-Mail an hallo@slimcore.io kontaktieren, werden Ihre Mail-Adresse, Ihr Name (sofern angegeben) und der Inhalt Ihrer Nachricht zur Bearbeitung der Anfrage gespeichert (Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO — Vertragsanbahnung — oder lit. f — berechtigtes Interesse an der Beantwortung).
</p>
<p class="mt-3">
E-Mail-Inhalte werden so lange aufbewahrt, wie es zur Bearbeitung Ihrer Anfrage erforderlich ist, längstens jedoch 24 Monate, soweit keine gesetzlichen Aufbewahrungspflichten entgegenstehen.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">7. Auftragsverarbeiter</h2>
<p class="mt-3 mb-6">
Folgende Dienstleister verarbeiten in unserem Auftrag personenbezogene Daten:
</p>
<div class="flex flex-col gap-5">
{processors.map((p) => (
<div class="border-l-2 border-[var(--color-accent)] pl-4">
<p class="font-serif text-[1.125rem] font-medium text-[var(--color-text-primary)]">{p.name}</p>
<p class="mt-1 font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">{p.country}</p>
<p class="mt-2 text-[0.9375rem]">{p.purpose}</p>
<p class="mt-2 text-[0.9375rem] text-[var(--color-text-tertiary)]">Rechtsgrundlage: {p.basis}</p>
</div>
))}
</div>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">8. Ihre Rechte</h2>
<p class="mt-3">
Sie haben jederzeit das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung (Art. 18), Datenübertragbarkeit (Art. 20) und Widerspruch (Art. 21) gegen die Verarbeitung Ihrer Daten. Bei einer Verarbeitung auf Grundlage einer Einwilligung können Sie diese jederzeit mit Wirkung für die Zukunft widerrufen.
</p>
<p class="mt-3">
Anfragen richten Sie bitte an <a href="mailto:hallo@slimcore.io" class="underline underline-offset-2 hover:text-[var(--color-accent)]">hallo@slimcore.io</a>.
</p>
<p class="mt-3">
Sie haben außerdem das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren — zuständig ist die Aufsichtsbehörde des Bundeslandes, in dem die digiFORMER GmbH ihren Sitz hat.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">9. Änderungen dieser Erklärung</h2>
<p class="mt-3">
Diese Datenschutzerklärung wird angepasst, wenn sich Verarbeitungsprozesse ändern. Die jeweils aktuelle Version finden Sie unter slimcore.io/datenschutz.
</p>
<p class="mt-3 font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
Stand: 2026-05-04
</p>
</div>
<div class="rounded border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<p class="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">Hinweis</p>
<p class="mt-2 text-[0.9375rem]">
Dies ist ein vorläufiges Template, das die Phase-1-Realität abbildet (keine Cookies, keine Analytics, keine Form-Submission). Vor Production-Launch durch eine Datenschutzberatung freigeben lassen. Bei Aktivierung des Tester-Formulars in Phase 3.5 müssen die Verarbeitungs-Vorgänge entsprechend ergänzt werden (Form-Daten, Doppel-Opt-in, Altcha).
</p>
</div>
</div>
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,255 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import Eyebrow from '@/components/marketing/Eyebrow.astro';
import StatusDot from '@/components/marketing/StatusDot.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import NumberedItem from '@/components/marketing/NumberedItem.astro';
import ModuleCard from '@/components/marketing/ModuleCard.astro';
import ModuleGrid from '@/components/marketing/ModuleGrid.astro';
import TechStrip from '@/components/marketing/TechStrip.astro';
import SovereigntyBlock from '@/components/marketing/SovereigntyBlock.astro';
import RoadmapTimeline from '@/components/marketing/RoadmapTimeline.astro';
import ObjectionAnswer from '@/components/marketing/ObjectionAnswer.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
import type { ModuleStatus } from '@/content/module';
const allStatuses: ModuleStatus[] = ['available', 'developing', 'planned', 'vision'];
const sovereigntyItems = [
{ label: '01 · HOSTING', body: 'Hetzner-Rechenzentren in Falkenstein und Nürnberg. Deutsches Recht, DSGVO, kein US-Anbieter im Datenfluss.' },
{ label: '02 · STACK', body: 'PostgreSQL, PostgREST, Docker. Ausschließlich Open-Source-Komponenten unter freien Lizenzen.' },
{ label: '03 · EXPORT', body: 'Volle Datenexporte in offenen Formaten — jederzeit, ohne Aufpreis, ohne Zustimmung von uns.' },
{ label: '04 · EGRESS', body: 'Optionaler Compliance-Layer mit Egress-Audit und -Proxy. Sie sehen, was Ihr Mandant sendet.' },
];
const roadmapPhases = [
{
label: 'Heute',
description: 'Was heute produktiv genutzt wird.',
current: true,
items: [
{ name: 'CRM', status: 'available' as const },
{ name: 'Lager', status: 'available' as const },
{ name: 'Bestellungen', status: 'available' as const },
{ name: 'Belege', status: 'available' as const },
{ name: 'Zahlungen', status: 'available' as const },
{ name: 'BuHa-Export (DATEV)', status: 'available' as const },
],
},
{
label: 'Q3Q4 2026',
description: 'Im aktiven Bau, kommt in den nächsten Monaten.',
items: [
{ name: 'Team-E-Mail', status: 'developing' as const },
{ name: 'Artikel', status: 'developing' as const },
{ name: 'Verkaufskanäle', status: 'developing' as const },
{ name: 'Versand', status: 'developing' as const },
{ name: 'Rechnungen (ZUGFeRD)', status: 'developing' as const },
{ name: 'Aufgaben', status: 'developing' as const },
],
},
{
label: '2027',
description: 'Feste Roadmap, Reihenfolge nach Tester-Feedback.',
items: [
{ name: 'Helpdesk', status: 'planned' as const },
{ name: 'Telefonie', status: 'planned' as const },
{ name: 'Einkauf', status: 'planned' as const },
{ name: 'Projekte', status: 'planned' as const },
{ name: 'Zeiterfassung', status: 'planned' as const },
{ name: 'Personal', status: 'planned' as const },
],
},
{
label: 'Vision',
description: 'Richtung, kein Versprechen.',
items: [
{ name: 'WhatsApp Business', status: 'vision' as const },
{ name: 'KI-Assistent', status: 'vision' as const },
],
},
];
const stackItems = ['PostgreSQL', 'PostgREST', 'Hetzner DE', 'DSGVO', 'ZUGFeRD 2.0', 'DATEV'];
const sectionDivider = 'mt-20 border-t border-[var(--color-border)] pt-12';
const componentLabel = 'mb-6 inline-block bg-[var(--color-text-primary)] px-2 py-1 font-mono text-[10px] font-medium uppercase tracking-[0.08em] text-[var(--color-bg-base)]';
---
<BaseLayout title="Component Showcase (Dev)" description="Interne Komponenten-Übersicht für visuelle Regression.">
<div class="mx-auto max-w-[1100px] px-6 py-16 md:px-10 md:py-20 xl:px-12">
<Eyebrow tone="accent" prefix="▸">Dev · Component Showcase</Eyebrow>
<h1 class="mt-3 font-serif text-[2.5rem] font-medium leading-[1.1] text-[var(--color-text-primary)]">
Komponenten-Bibliothek
</h1>
<p class="mt-4 max-w-[60ch] text-[1.0625rem] leading-relaxed text-[var(--color-text-secondary)]">
Diese Seite ist nur intern. Sie zeigt jede Komponente in jeder Variante zur visuellen Abnahme. Nicht im Sitemap verlinkt.
</p>
{/* — 1. Eyebrow — */}
<section class={sectionDivider}>
<span class={componentLabel}>Eyebrow</span>
<div class="flex flex-col gap-4 bg-[var(--color-bg-surface)] p-6">
<Eyebrow>Default · tertiärer Ton</Eyebrow>
<Eyebrow tone="accent" prefix="▸">Hero-Variante mit Persimmon und ▸-Prefix</Eyebrow>
<Eyebrow status="IN ENTWICKLUNG">Mit Status-Pille</Eyebrow>
</div>
<div class="mt-2 bg-[#0E0F14] p-6">
<Eyebrow tone="inverse">Inverse-Variante (auf dunklem Grund)</Eyebrow>
</div>
</section>
{/* — 2. StatusDot — */}
<section class={sectionDivider}>
<span class={componentLabel}>StatusDot</span>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap gap-8 bg-[var(--color-bg-surface)] p-6">
{allStatuses.map((s) => <StatusDot status={s} label />)}
</div>
<div class="flex flex-wrap gap-8 bg-[#0E0F14] p-6 text-[#F5F5F0]">
{allStatuses.map((s) => <StatusDot status={s} label inverse />)}
</div>
</div>
</section>
{/* — 3. SectionHeading — */}
<section class={sectionDivider}>
<span class={componentLabel}>SectionHeading</span>
<div class="flex flex-col gap-10 bg-[var(--color-bg-surface)] p-6">
<SectionHeading
eyebrow="WAS UNS TRENNT"
subtitle="Best-Practices der großen Tools, bedienbar wie ein modernes SaaS."
>
Drei Differenzierungen, die zählen.
</SectionHeading>
<SectionHeading display>
Display-Variante ohne Eyebrow.
</SectionHeading>
</div>
</section>
{/* — 4. NumberedItem — */}
<section class={sectionDivider}>
<span class={componentLabel}>NumberedItem</span>
<div class="grid grid-cols-1 gap-8 bg-[var(--color-bg-surface)] p-6 md:grid-cols-3">
<NumberedItem number="01" title="Schlank von Tag 1">
Sie aktivieren das, was Sie heute brauchen — meistens CRM, Belege und Aufgaben.
</NumberedItem>
<NumberedItem number="02" title="Wächst mit">
Wenn aus Ihnen drei werden, dann fünfzehn, bleiben Sie in derselben Software.
</NumberedItem>
<NumberedItem number="03" title="Souverän">
In Deutschland gehostet, Open-Source-Stack, Datenexport jederzeit.
</NumberedItem>
</div>
</section>
{/* — 5. ModuleCard (einzeln) — */}
<section class={sectionDivider}>
<span class={componentLabel}>ModuleCard (einzeln)</span>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<ModuleCard
pillarNumber="01"
pillarTitle="Kunden & Kommunikation"
modules={[
{ name: 'CRM', status: 'available' },
{ name: 'Team-E-Mail', status: 'developing' },
{ name: 'Helpdesk', status: 'planned' },
{ name: 'Telefonie', status: 'planned' },
{ name: 'WhatsApp', status: 'vision' },
]}
/>
<ModuleCard
pillarNumber="03"
pillarTitle="Belege & Finanzen"
modules={[
{ name: 'Belege', status: 'available' },
{ name: 'Rechnungen', status: 'developing' },
{ name: 'Zahlungen', status: 'available' },
{ name: 'BuHa-Export', status: 'available' },
]}
/>
</div>
</section>
{/* — 6. ModuleGrid (komplett) — */}
<section class={sectionDivider}>
<span class={componentLabel}>ModuleGrid</span>
<ModuleGrid />
</section>
</div>
{/* — 7. TechStrip (full-bleed) — */}
<section class="mt-12">
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<span class={componentLabel}>TechStrip · dark</span>
</div>
<TechStrip items={stackItems} variant="dark" />
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<span class={`${componentLabel} mt-8`}>TechStrip · light</span>
</div>
<TechStrip items={stackItems} variant="light" />
</section>
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
{/* — 8. SovereigntyBlock — */}
<section class={sectionDivider}>
<span class={componentLabel}>SovereigntyBlock</span>
<SovereigntyBlock
headlineLines={['Ihre Daten.', 'Ihr Land.', 'Ihre Kontrolle.']}
lead="Souveränität ist bei SlimCore kein Marketing-Begriff, sondern eine Architektur-Entscheidung. Vier konkrete Punkte zeigen, was das im Alltag bedeutet."
items={sovereigntyItems}
/>
</section>
{/* — 9. RoadmapTimeline — */}
<section class={sectionDivider}>
<span class={componentLabel}>RoadmapTimeline</span>
<RoadmapTimeline phases={roadmapPhases} />
</section>
{/* — 10. ObjectionAnswer — */}
<section class={sectionDivider}>
<span class={componentLabel}>ObjectionAnswer</span>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<ObjectionAnswer question="Wir sind nur zwei. Lohnt sich das?">
Genau für Sie gebaut. Drei Module reichen zum Start — und wachsen mit, wenn Sie wachsen.
</ObjectionAnswer>
<ObjectionAnswer question="Was, wenn ihr verschwindet?">
Voller Export in offenem Schema. Jeder PostgreSQL-Hoster führt Ihren Mandanten weiter.
</ObjectionAnswer>
</div>
</section>
{/* — 11. CTABlock — */}
<section class={sectionDivider}>
<span class={componentLabel}>CTABlock · light</span>
<CTABlock
eyebrow="TESTER-PROGRAMM · PHASE 1"
headline="Sie kennen die Lücken besser als jeder Berater."
body="Helfen Sie uns, die Geschäftssoftware zu bauen, die Sie hoffentlich die nächsten zehn Jahre nutzen wollen."
ctas={[
{ label: 'Tester werden', href: '/tester', variant: 'primary' },
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io', variant: 'ghost' },
]}
/>
</section>
</div>
<section class="mt-20 bg-[#0E0F14] py-20">
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<span class="mb-6 inline-block bg-[#F5F5F0] px-2 py-1 font-mono text-[10px] font-medium uppercase tracking-[0.08em] text-[#0E0F14]">
CTABlock · inverse
</span>
<CTABlock
inverse
eyebrow="30 MINUTEN · UNVERBINDLICH"
headline="Lassen Sie uns ehrlich gucken, ob SlimCore zu Ihrer Situation passt."
ctas={[
{ label: 'Tester werden', href: '/tester', variant: 'primary' },
{ label: 'Termin vereinbaren', href: 'https://calendly.com/digiformer/quick-call', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>

117
src/pages/en/contact.astro Normal file
View file

@ -0,0 +1,117 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
lang="en"
title="Contact"
description="How to reach us: by mail, by call, or by post. No contact form — the tester sign-up is the primary channel, anything else is faster by mail."
>
{/* — Hero — */}
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="CONTACT"
subtitle="We prefer the direct line. Mail or a call is faster than any form — you write, we reply personally, usually the same day."
>
How to reach us.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{/* — Mail + Call Block — */}
<section class={sectionPad}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
THREE WAYS
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
Mail, call, or post.
</h2>
</div>
<div class="md:col-span-7 flex flex-col gap-10">
<div class="border-l-2 border-[var(--color-accent)] pl-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
01 · MAIL
</p>
<p class="mt-2">
<a href="mailto:hallo@slimcore.io" class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]">
hallo@slimcore.io
</a>
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
The fastest path for most matters. Reply usually the same day, latest the next business day.
</p>
</div>
<div class="border-l-2 border-[var(--color-accent)] pl-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
02 · CALL
</p>
<p class="mt-2">
<a
href="https://calendly.com/digiformer/quick-call"
target="_blank"
rel="noopener noreferrer"
class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
Book 30 minutes online <span aria-hidden="true">↗</span>
</a>
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
External Calendly link. We do not capture tracking data on slimcore.io — clicking opens Calendly in a new tab. Calendly is a transitional reality until our own SlimCore booking module is ready.
</p>
</div>
<div class="border-l-2 border-[var(--color-accent)] pl-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
03 · POST
</p>
<address class="mt-2 not-italic font-serif text-[1.125rem] leading-[1.5] text-[var(--color-text-primary)]">
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning<br />
Germany
</address>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
Postal address for formal correspondence. Full address and management in the <a href="/en/imprint" class="underline underline-offset-2 hover:text-[var(--color-accent)]">imprint</a>.
</p>
</div>
</div>
</div>
</div>
</section>
{/* — Tester is primary funnel — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
FOR TESTER REQUESTS
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
Please head to the tester page.
</h2>
</div>
<div class="md:col-span-7">
<p class="text-[1.0625rem] leading-relaxed text-[var(--color-text-secondary)]">
If you want to use SlimCore as a tester, the <a href="/en/tester" class="underline underline-offset-2 hover:text-[var(--color-accent)]">tester page</a> is the right entry. It explains who we are looking for, what testers receive, and what we hope for. Sign-up currently runs by mail.
</p>
</div>
</div>
</div>
</section>
</BaseLayout>

115
src/pages/en/imprint.astro Normal file
View file

@ -0,0 +1,115 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
const sectionPad = 'py-16 md:py-20';
const container = 'mx-auto max-w-[820px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
lang="en"
title="Imprint"
description="Legal notice (Impressum) for slimcore.io — a product of digiFORMER GmbH, Germany."
>
<section class="bg-[#0E0F14] pt-[112px] pb-12 md:pt-[140px] md:pb-16">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="LEGAL NOTICE · §5 TMG"
>
Imprint
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
<section class={sectionPad}>
<div class={container}>
<div class="flex flex-col gap-10 text-[1rem] leading-[1.7] text-[var(--color-text-secondary)]">
<div class="rounded border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<p class="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">Note</p>
<p class="mt-2 text-[0.9375rem]">
German law requires the imprint in German. The English version below is informational and mirrors the German content for international visitors. The legally binding version is the <a href="/impressum" class="underline underline-offset-2 hover:text-[var(--color-accent)]">German imprint</a>.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Provider</h2>
<address class="mt-3 not-italic">
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning<br />
Germany
</address>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Authorised representative (Geschäftsführung)</h2>
<p class="mt-3">Pascal Oelmann</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Contact</h2>
<p class="mt-3">
Phone: <a href="tel:+4981217671700" class="underline underline-offset-2 hover:text-[var(--color-accent)]">+49 (0) 8121 76717-0</a><br />
Email: <a href="mailto:hallo@slimcore.io" class="underline underline-offset-2 hover:text-[var(--color-accent)]">hallo@slimcore.io</a>
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Commercial register</h2>
<p class="mt-3">
Registered at the German commercial register (Handelsregister).<br />
Court (Registergericht): Amtsgericht München<br />
Number: HRB 248468
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">VAT identification</h2>
<p class="mt-3">
VAT ID per §27 a German VAT Act:<br />
DE310218819
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Editorial responsibility per §55 Abs. 2 RStV</h2>
<p class="mt-3">
Pascal Oelmann<br />
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">EU online dispute resolution</h2>
<p class="mt-3">
The European Commission provides a platform for online dispute resolution:
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer" class="underline underline-offset-2 hover:text-[var(--color-accent)]">https://ec.europa.eu/consumers/odr/</a><br />
Our email address is given above.
</p>
<p class="mt-3">
We are not willing or obliged to participate in dispute resolution procedures before a consumer arbitration body.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Liability</h2>
<p class="mt-3">
The contents of this website have been compiled with care. However, we cannot assume any liability for the accuracy, completeness, or timeliness of the content.
</p>
<p class="mt-3">
As a service provider, we are responsible for our own content on these pages under §7 Abs. 1 TMG. According to §§8 to 10 TMG, we are not obliged to monitor transmitted or stored third-party information or to investigate circumstances that indicate illegal activity.
</p>
</div>
</div>
</div>
</section>
</BaseLayout>

297
src/pages/en/index.astro Normal file
View file

@ -0,0 +1,297 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import TechStrip from '@/components/marketing/TechStrip.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import NumberedItem from '@/components/marketing/NumberedItem.astro';
import ModuleGrid from '@/components/marketing/ModuleGrid.astro';
import SovereigntyBlock from '@/components/marketing/SovereigntyBlock.astro';
import RoadmapTimeline from '@/components/marketing/RoadmapTimeline.astro';
import ObjectionAnswer from '@/components/marketing/ObjectionAnswer.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
import { modules } from '@/content/module';
const stackItems = ['PostgreSQL', 'PostgREST', 'Hetzner DE', 'GDPR', 'ZUGFeRD 2.0', 'DATEV'];
const moduleCount = modules.length;
const sovereigntyItems = [
{ label: '01 · HOSTING', body: 'Hetzner data centres in Falkenstein and Nuremberg. German law, GDPR, no US provider in the data path.' },
{ label: '02 · STACK', body: 'PostgreSQL, PostgREST, Docker. Open-source components under permissive licences only.' },
{ label: '03 · EXPORT', body: 'Full data exports in open formats — anytime, no extra fee, no approval required from us.' },
{ label: '04 · EGRESS', body: 'Optional compliance layer with egress audit and proxy. You see what your tenant sends.' },
];
const roadmapPhases = [
{
label: 'Today',
description: 'What is in production use today.',
current: true,
items: [
{ name: 'CRM', status: 'available' as const },
{ name: 'Inventory', status: 'available' as const },
{ name: 'Orders', status: 'available' as const },
{ name: 'Documents', status: 'available' as const },
{ name: 'Payments', status: 'available' as const },
{ name: 'Accounting export (DATEV)', status: 'available' as const },
],
},
{
label: 'Q3Q4 2026',
description: 'Active development, shipping in the coming months.',
items: [
{ name: 'Team email', status: 'developing' as const },
{ name: 'Items', status: 'developing' as const },
{ name: 'Sales channels', status: 'developing' as const },
{ name: 'Shipping', status: 'developing' as const },
{ name: 'Invoices (ZUGFeRD)', status: 'developing' as const },
{ name: 'Tasks', status: 'developing' as const },
],
},
{
label: '2027',
description: 'Committed roadmap, sequencing follows tester feedback.',
items: [
{ name: 'Helpdesk', status: 'planned' as const },
{ name: 'Telephony', status: 'planned' as const },
{ name: 'Purchasing', status: 'planned' as const },
{ name: 'Projects', status: 'planned' as const },
{ name: 'Time tracking', status: 'planned' as const },
{ name: 'HR', status: 'planned' as const },
],
},
{
label: 'Vision',
description: 'Direction, not promise.',
items: [
{ name: 'WhatsApp Business', status: 'vision' as const },
{ name: 'AI assistant', status: 'vision' as const },
],
},
];
const objections = [
{
q: 'We are only two. Is this even worth it?',
a: 'Built exactly for you. Three modules are enough to start — and grow with you when you grow. No 27-module suite to wade through.',
},
{
q: 'What if we grow?',
a: 'SlimCore scales from one person to roughly fifty — same software, just more activated modules. No platform switch, no migration, no second training.',
},
{
q: 'We already use HubSpot.',
a: 'Keep it. SlimCore closes the gap between pipeline and documents — exactly where HubSpot ends and your accountant begins.',
},
{
q: 'Odoo was too complex.',
a: 'SlimCore delivers about 80% of Odoo without the implementation overhead. What you do not need, you do not see.',
},
{
q: 'What if you disappear?',
a: 'Full export in open schema. Any PostgreSQL host can keep your tenant running. No lock-in, not even from us.',
},
];
const sectionPad = 'py-20 md:py-28 xl:py-32';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
lang="en"
title="Start lean. Infinite growth."
description="The lean business software for solopreneurs and small teams. CRM, documents, tasks — modular, hosted in Germany, open source, no vendor lock-in."
>
{/* — 1. Hero — */}
<section class="hero relative bg-[#0E0F14] pt-[112px] pb-12 md:pt-[140px] md:pb-12">
<div class={container}>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.10em] text-[#FF6B2C]">
<span aria-hidden="true">▸</span> Business software for solopreneurs and small teams
</p>
<h1 class="hero-headline mt-6 max-w-[18ch] font-serif font-medium text-[#F5F5F0]">
Start lean.<br /><span class="hero-highlight">Infinite</span> growth.
</h1>
<p class="mt-6 max-w-[540px] text-[17px] leading-[1.6] text-[rgba(245,245,240,0.75)]">
The lean business software for solopreneurs and small teams. You start by activating only what you need today — usually CRM, documents, and tasks. When you grow from one to three, then fifteen, you turn on more modules without switching tools.
</p>
<div class="mt-8 flex flex-wrap gap-3">
<a
href="/en/tester"
class="inline-flex items-center gap-1 rounded-md bg-[#FF6B2C] px-5 py-[11px] text-[13px] font-medium text-[#2A0F02] transition-colors hover:bg-[var(--color-accent-hover)]"
>
Become a tester <span aria-hidden="true">→</span>
</a>
<a
href="/en/module"
class="inline-flex items-center gap-1 rounded-md border-[0.5px] border-[rgba(245,245,240,0.5)] bg-transparent px-5 py-[11px] text-[13px] font-medium text-[#F5F5F0] transition-colors hover:bg-[rgba(245,245,240,0.06)]"
>
See modules <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 2. Tech-Strip — */}
<TechStrip items={stackItems} variant="dark" />
{/* Sentinel: Markiert das Ende der dunklen Zone für die NavBar */}
<div data-hero-end aria-hidden="true"></div>
{/* — 3. Modul-Landschaft — */}
<section class={sectionPad}>
<div class={container}>
<SectionHeading
eyebrow={`FOUR PILLARS · ${moduleCount} MODULES`}
subtitle="Every module activates individually. Today you start with CRM, documents and tasks — tomorrow you add shipping and team email, no migration, no platform switch."
>
Activate only what you need today.
</SectionHeading>
<div class="mt-12">
<ModuleGrid lang="en" />
</div>
<div class="mt-10">
<a
href="/en/module"
class="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
All modules &amp; status <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 4. Was uns trennt — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<SectionHeading eyebrow="WHAT SETS US APART">
Best practices from the big tools. Usable like modern SaaS.
</SectionHeading>
<div class="mt-12 grid grid-cols-1 gap-10 md:grid-cols-3 md:gap-8">
<NumberedItem number="01" title="Lean from day 1">
You activate what you need today — usually CRM, documents and tasks. No onboarding marathon, no 27-module suite you will never fully use.
</NumberedItem>
<NumberedItem number="02" title="Grows with you">
When you grow from one to three, then fifteen, you stay in the same software. Team inbox, inventory, shipping, workflow — turn them on when you actually need them, without migrating.
</NumberedItem>
<NumberedItem number="03" title="Sovereign">
Hosted in Germany, open-source stack, data export anytime. No US cloud, no vendor lock-in, no licence terms rewritten next year.
</NumberedItem>
</div>
</div>
</section>
{/* — 5. Souveränität — */}
<section class={sectionPad}>
<div class={container}>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
The big promise
</p>
<div class="mt-8">
<SovereigntyBlock
headlineLines={['Your data.', 'Your country.', 'Your control.']}
lead="At SlimCore, sovereignty is not a marketing term — it is an architecture decision. Four concrete points show what that means in daily use."
items={sovereigntyItems}
/>
</div>
<div class="mt-12">
<a
href="/en/sovereignty"
class="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
More on sovereignty <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 6. Roadmap-Snapshot — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<SectionHeading eyebrow="ROADMAP">
Today. Soon. Vision.
</SectionHeading>
<div class="mt-12">
<RoadmapTimeline phases={roadmapPhases} lang="en" />
</div>
<div class="mt-12">
<a
href="/en/roadmap"
class="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
Full roadmap <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 7. Schnell-Argumentarium — */}
<section class={sectionPad}>
<div class={container}>
<SectionHeading eyebrow="FIVE OBJECTIONS, FIVE ANSWERS">
Answers you usually only get on the third call.
</SectionHeading>
<div class="mt-12 grid grid-cols-1 gap-x-10 gap-y-8 md:grid-cols-2">
{objections.map((o) => (
<ObjectionAnswer question={o.q}>{o.a}</ObjectionAnswer>
))}
</div>
</div>
</section>
{/* — 8. Tester-Programm-Teaser — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<CTABlock
eyebrow="TESTER PROGRAM · PHASE 1"
headline="You know the gaps better than any consultant."
body="Help us build the business software you would hopefully want to use for the next ten years. In return: early influence, discounted or free access — and a direct line to the team."
ctas={[
{ label: 'Become a tester', href: '/en/tester', variant: 'primary' },
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io', variant: 'ghost' },
]}
/>
</div>
</section>
{/* — 9. Final-CTA — */}
<section class="bg-[#0E0F14] py-20 md:py-28 xl:py-32">
<div class={container}>
<CTABlock
inverse
eyebrow="30 MINUTES · NO COMMITMENT"
headline="Tell us where you stand — we will say honestly whether SlimCore fits your situation."
ctas={[
{ label: 'Become a tester', href: '/en/tester', variant: 'primary' },
{ label: 'Book a call', href: 'https://calendly.com/digiformer/quick-call', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>
<style>
.hero-headline {
font-size: clamp(38px, 5vw, 56px);
line-height: 1.18;
letter-spacing: -0.015em;
}
.hero-highlight {
display: inline-block;
background-color: #ff6b2c;
color: #2a0f02;
padding: 0.04em 0.2em 0.08em;
line-height: 0.95;
font-weight: 550;
vertical-align: baseline;
border-radius: 0;
}
</style>

63
src/pages/en/module.astro Normal file
View file

@ -0,0 +1,63 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import ModuleFilter from '@/components/islands/ModuleFilter.tsx';
import {
modules,
pillarTitlesEn,
statusLabelEn,
getModuleName,
getPillarTitle,
} from '@/content/module';
import { t } from '@/i18n/strings';
const lang = 'en' as const;
const s = t(lang);
const serializedModules = modules.map((m) => ({
id: m.id,
name: getModuleName(m, lang),
pillar: m.pillar,
pillarTitle: getPillarTitle(m.pillar, lang),
status: m.status,
description: m.descriptionEn,
}));
const labels = {
filterStatusHeading: s.filter.filterStatusHeading,
filterPillarHeading: s.filter.filterPillarHeading,
allLabel: s.filter.allLabel,
statusLabels: statusLabelEn,
pillarTitles: pillarTitlesEn,
resultsSingular: s.filter.resultsSingular,
resultsPlural: s.filter.resultsPlural,
};
---
<BaseLayout
lang="en"
title="Modules — What you can activate"
description="19 modules in 4 pillars. What is in production today, what is being built, what is on the roadmap. Activate three today, add the fourth tomorrow."
>
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow={`MODULES · ${modules.length} TOTAL`}
subtitle="What you can activate — and at what level of maturity. Filter by status or pillar to quickly see what is in production today and what is shipping in the coming months."
>
Modules
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
<section class="py-16">
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<ModuleFilter client:load modules={serializedModules} labels={labels} lang={lang} />
</div>
</section>
</BaseLayout>

142
src/pages/en/privacy.astro Normal file
View file

@ -0,0 +1,142 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
const sectionPad = 'py-16 md:py-20';
const container = 'mx-auto max-w-[820px] px-6 md:px-10 xl:px-12';
const processors = [
{ name: 'Hetzner Online GmbH', country: 'Germany', purpose: 'Hosting of the website and application. Servers in Falkenstein and Nuremberg.', basis: 'Data processing agreement (DPA) per Art. 28 GDPR.' },
{ name: 'Brevo SAS (Sendinblue)', country: 'France', purpose: 'Sending transactional emails (e.g. confirmation messages).', basis: 'Data processing agreement (DPA) per Art. 28 GDPR.' },
{ name: 'Calendly LLC', country: 'USA · transitional reality', purpose: 'Appointment booking via external link. No embedding on slimcore.io. Data processing only starts after clicking the Calendly link.', basis: 'External link — Calendlys own privacy policy applies. Will be replaced by SlimCores own booking module.' },
];
---
<BaseLayout
lang="en"
title="Privacy"
description="How we handle personal data on slimcore.io — processors, legal bases, your rights. No analytics, no tracking cookies."
>
<section class="bg-[#0E0F14] pt-[112px] pb-12 md:pt-[140px] md:pb-16">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="GDPR · ART. 13"
>
Privacy
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
<section class={sectionPad}>
<div class={container}>
<div class="flex flex-col gap-12 text-[1rem] leading-[1.7] text-[var(--color-text-secondary)]">
<div class="rounded border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<p class="font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">Note</p>
<p class="mt-2 text-[0.9375rem]">
German law requires the legally binding privacy policy in German. The English version below is informational. The legally binding version is the <a href="/datenschutz" class="underline underline-offset-2 hover:text-[var(--color-accent)]">German privacy policy</a>.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">1. Controller</h2>
<p class="mt-3">Controller in the sense of the GDPR:</p>
<address class="mt-3 not-italic">
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning<br />
Germany<br />
Email: <a href="mailto:hallo@slimcore.io" class="underline underline-offset-2 hover:text-[var(--color-accent)]">hallo@slimcore.io</a>
</address>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">2. Data on website visit</h2>
<p class="mt-3">
When visiting slimcore.io, technically necessary data is transmitted to our web server — IP address, user agent, timestamp, requested URL, referrer. This data is processed solely to deliver the page and protect against technical attacks (legal basis: Art. 6 (1) (f) GDPR — legitimate interest). It is not analysed for tracking purposes.
</p>
<p class="mt-3">
Server logs are deleted after 14 days.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">3. Cookies</h2>
<p class="mt-3">
slimcore.io sets <strong>no cookies</strong>. No tracking cookies, no advertising cookies, no third-party cookies. Therefore no cookie banner is required.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">4. Analytics</h2>
<p class="mt-3">
We use <strong>no web analytics</strong>. No Google Analytics, no Plausible, no Pirsch, no Matomo. We do not know who spent how long on which page.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">5. Fonts</h2>
<p class="mt-3">
Fonts (Outfit, JetBrains Mono) are served directly from slimcore.io (self-hosted). No connection to Google Fonts or other external font CDNs.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">6. Email contact</h2>
<p class="mt-3">
If you contact us by email at hallo@slimcore.io, your email address, your name (if provided), and the content of your message are stored to handle your request (legal basis: Art. 6 (1) (b) GDPR — pre-contractual measures — or (f) — legitimate interest in answering).
</p>
<p class="mt-3">
Email content is retained as long as necessary to handle your request, at most 24 months, unless statutory retention obligations apply.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">7. Processors</h2>
<p class="mt-3 mb-6">
The following service providers process personal data on our behalf:
</p>
<div class="flex flex-col gap-5">
{processors.map((p) => (
<div class="border-l-2 border-[var(--color-accent)] pl-4">
<p class="font-serif text-[1.125rem] font-medium text-[var(--color-text-primary)]">{p.name}</p>
<p class="mt-1 font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">{p.country}</p>
<p class="mt-2 text-[0.9375rem]">{p.purpose}</p>
<p class="mt-2 text-[0.9375rem] text-[var(--color-text-tertiary)]">Legal basis: {p.basis}</p>
</div>
))}
</div>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">8. Your rights</h2>
<p class="mt-3">
You have the right to access (Art. 15 GDPR), rectification (Art. 16), erasure (Art. 17), restriction (Art. 18), data portability (Art. 20), and to object (Art. 21) to the processing of your data. Where processing is based on consent, you may withdraw it at any time with effect for the future.
</p>
<p class="mt-3">
Requests go to <a href="mailto:hallo@slimcore.io" class="underline underline-offset-2 hover:text-[var(--color-accent)]">hallo@slimcore.io</a>.
</p>
<p class="mt-3">
You also have the right to lodge a complaint with a data protection supervisory authority — the responsible authority is the one of the federal state in which digiFORMER GmbH is registered.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">9. Changes to this policy</h2>
<p class="mt-3">
This privacy policy is updated when processing operations change. The current version is always available at slimcore.io/datenschutz.
</p>
<p class="mt-3 font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
Last updated: 2026-05-04
</p>
</div>
</div>
</div>
</section>
</BaseLayout>

113
src/pages/en/roadmap.astro Normal file
View file

@ -0,0 +1,113 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import StatusDot from '@/components/marketing/StatusDot.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
import { modules, getStatusLabel, getModuleName, getPillarTitle } from '@/content/module';
import type { ModuleStatus } from '@/content/module';
const lang = 'en' as const;
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
interface Phase {
label: string;
current?: boolean;
description: string;
status: ModuleStatus;
}
const phases: Phase[] = [
{ label: 'Today', current: true, description: 'These modules are in production today. Mature enough that you can use them right away as a tester.', status: 'available' },
{ label: 'Q3Q4 2026', description: 'Active development. Sequencing follows tester feedback.', status: 'developing' },
{ label: '2027', description: 'Committed roadmap, sequencing follows tester demand. Beta phases typically 48 weeks per module.', status: 'planned' },
{ label: 'Vision', description: 'Direction, not promise. We name it because it is honest — not to sell it.', status: 'vision' },
];
---
<BaseLayout
lang="en"
title="Roadmap — Today, soon, vision"
description="What is in production today, what ships in Q3/Q4 2026, what comes in 2027, and where we are heading. Full module list with current status per phase."
>
{/* — Hero — */}
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="ROADMAP"
subtitle="Today, soon, vision. Full module list with current maturity. No vision item is sold as available, nothing available is hidden."
>
Today. Soon. Vision.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{phases.map((phase, idx) => {
const phaseModules = modules.filter((m) => m.status === phase.status);
const surface = idx % 2 === 0 ? '' : 'bg-[var(--color-bg-surface)]';
return (
<section class:list={[sectionPad, surface]}>
<div class={container}>
<div class="flex flex-wrap items-baseline gap-3">
<span class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{phase.label}
</span>
{phase.current && (
<span class="bg-[var(--color-accent)] px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-on-accent)]">
Current
</span>
)}
<span class="font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
· {phaseModules.length} {phaseModules.length === 1 ? 'module' : 'modules'}
</span>
</div>
<h2 class="mt-3 max-w-[20ch] font-serif text-[2rem] md:text-[2.5rem] font-medium leading-[1.15] tracking-[-0.005em] text-[var(--color-text-primary)]">
{getStatusLabel(phase.status, lang)}
</h2>
<p class="mt-4 max-w-[60ch] text-[1.0625rem] leading-relaxed text-[var(--color-text-secondary)]">
{phase.description}
</p>
<ul class="mt-10 grid grid-cols-1 gap-4 md:grid-cols-2">
{phaseModules.map((m) => (
<li class="flex flex-col gap-2 border-l-2 border-[var(--color-border-strong)] pl-4">
<div class="flex items-baseline gap-3">
<h3 class="font-serif text-[1.125rem] font-medium text-[var(--color-text-primary)]">
{getModuleName(m, lang)}
</h3>
<span class="font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
{m.pillar} · {getPillarTitle(m.pillar, lang)}
</span>
<StatusDot status={m.status} lang={lang} />
</div>
<p class="text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
{m.descriptionEn}
</p>
</li>
))}
</ul>
</div>
</section>
);
})}
<section class="bg-[#0E0F14] py-20 md:py-28">
<div class={container}>
<CTABlock
inverse
eyebrow="ROADMAP FEEDBACK"
headline="Testers change the order."
body="Which module should ship in 2026 before which other? What is missing entirely? We sequence the roadmap by what our testers actually need."
ctas={[
{ label: 'Become a tester', href: '/en/tester', variant: 'primary' },
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io?subject=Roadmap%20feedback', variant: 'ghost' },
]}
/>
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,177 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import NumberedItem from '@/components/marketing/NumberedItem.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
const stack = [
{ name: 'PostgreSQL', license: 'PostgreSQL License', vendor: 'OSS', purpose: 'Database' },
{ name: 'PostgREST', license: 'MIT', vendor: 'OSS', purpose: 'API layer' },
{ name: 'Traefik', license: 'MIT', vendor: 'OSS', purpose: 'Reverse proxy + TLS' },
{ name: 'Docker', license: 'Apache 2.0', vendor: 'OSS', purpose: 'Container runtime' },
{ name: 'React', license: 'MIT', vendor: 'OSS (Meta)', purpose: 'UI framework' },
{ name: 'Astro', license: 'MIT', vendor: 'OSS', purpose: 'Marketing-site SSG' },
];
const noUSItems = [
{ label: 'AUTH', body: 'Auth layer based on PostgreSQL. Zitadel (CH) as the long-term target for federated identities.' },
{ label: 'MAIL', body: 'Brevo (FR) as transactional mail provider. No US providers in the mail flow.' },
{ label: 'STORAGE', body: 'Garage as a self-hosted object storage cluster, S3-API-compatible without S3.' },
{ label: 'PAYMENTS', body: 'Vivid Money (LU/DE) as business banking. Mollie (NL) as subscription billing in phase 3.' },
];
---
<BaseLayout
lang="en"
title="Sovereignty — how SlimCore solves it"
description="Hetzner data centres in Germany, open-source stack, no US provider in the data path, full data export in open formats. No marketing fluff — four concrete layers."
>
{/* — Hero — */}
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="THE BIG PROMISE"
subtitle="At SlimCore, sovereignty is not a marketing term — it is an architecture decision. Here is what that means in daily use, layer by layer, no-frills."
>
Your data. Your country. Your control.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{/* — 01 — Why this matters now — */}
<section class={sectionPad}>
<div class={container}>
<NumberedItem number="01" title="Why this matters now.">
<p class="mt-2">
CLOUD Act, Schrems II, geopolitical uncertainty: the legal landscape for US cloud providers in Europe has tightened, not loosened, over the last few years. Anyone writing customer data into a US cloud today does so knowing that a US authority can legally compel access — even when the data physically sits in a Frankfurt data centre.
</p>
<p class="mt-4">
This is not a conspiracy argument. It is the reading of the Schrems II decision by the European Court of Justice. We take it seriously because our testers do.
</p>
</NumberedItem>
</div>
</section>
{/* — 02 — Hosting — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<NumberedItem number="02" title="Hosting in Germany — not a marketing claim.">
<p class="mt-2">
Every SlimCore tenant runs on Hetzner servers in Germany. Locations: Falkenstein and Nuremberg. Contractual partner: Hetzner Online GmbH, Gunzenhausen — a GmbH under German law, no US parent, no offices outside the EU.
</p>
<p class="mt-4">
Not "processed in the EU". Actually on German soil, in data centres under the jurisdiction of German data protection authorities. GDPR is the minimum bar — not a sales argument.
</p>
</NumberedItem>
</div>
</section>
{/* — 03 — Open-source stack — */}
<section class={sectionPad}>
<div class={container}>
<NumberedItem number="03" title="Open-source stack, fully.">
<p class="mt-2 mb-8">
Every component in our stack is open-source under a permissive licence. We do not use a proprietary database, no closed auth provider, no closed-source application server. Two consequences: first, you can self-host the whole thing; second, no one can revoke your licence tomorrow.
</p>
<div class="overflow-hidden border border-[var(--color-border)] bg-[var(--color-bg-surface)]">
<table class="w-full text-left text-[0.9375rem]">
<thead>
<tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-base)]">
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Component</th>
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Licence</th>
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Vendor</th>
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Purpose</th>
</tr>
</thead>
<tbody>
{stack.map((row, i) => (
<tr class:list={[i < stack.length - 1 ? 'border-b border-[var(--color-border)]' : '']}>
<td class="px-4 py-3 font-mono text-[var(--color-text-primary)]">{row.name}</td>
<td class="px-4 py-3 text-[var(--color-text-secondary)]">{row.license}</td>
<td class="px-4 py-3 text-[var(--color-text-secondary)]">{row.vendor}</td>
<td class="px-4 py-3 text-[var(--color-text-secondary)]">{row.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</NumberedItem>
</div>
</section>
{/* — 04 — No US dependencies — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<NumberedItem number="04" title="No US dependencies — checked everywhere.">
<p class="mt-2 mb-8">
The hard rule in the brand system: every vendor in the critical data or application path is headquartered in DE, CH, AT, or the EU and operates its data centres in the EU. No exceptions. Not even US subsidiaries — because the parent corporation does not lift the CLOUD Act exposure.
</p>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{noUSItems.map((item) => (
<div class="border-l-2 border-[var(--color-accent)] pl-4">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{item.label}
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-primary)]">
{item.body}
</p>
</div>
))}
</div>
</NumberedItem>
</div>
</section>
{/* — 05 — No vendor lock-in — */}
<section class={sectionPad}>
<div class={container}>
<NumberedItem number="05" title="No vendor lock-in — your data stays yours.">
<p class="mt-2">
You can export your entire tenant as a ZIP at any time — structured data as JSON or CSV, documents as PDF, attachments in original formats. No proprietary containers, no paywall. The schema is openly documented. Any PostgreSQL host can keep your data running if you ever want to leave us.
</p>
<p class="mt-4">
Accounting exports happen in standard formats: DATEV CSV in production, sevDesk and Lexware APIs in preparation. You can switch your accountant without leaving us.
</p>
</NumberedItem>
</div>
</section>
{/* — 06 — Optional compliance layer — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<NumberedItem number="06" title="Optional: egress audit and egress proxy.">
<p class="mt-2">
For regulated industries or organisations with their own compliance requirements, we offer an optional compliance layer: egress audit (what your tenant sends out, to whom, when) plus egress proxy (controlled outbound connections with a whitelist).
</p>
<p class="mt-4">
This is not a standard feature, but for testers who need sovereignty as a verifiable property, not just a promise.
</p>
</NumberedItem>
</div>
</section>
{/* — Final CTA — */}
<section class="bg-[#0E0F14] py-20 md:py-28">
<div class={container}>
<CTABlock
inverse
eyebrow="NEXT"
headline="Sovereignty is architecture, not marketing."
body="If you have specific questions about how SlimCore meets your sovereignty requirements — drop us a line. 30 minutes is usually enough."
ctas={[
{ label: 'Book a call', href: 'https://calendly.com/digiformer/quick-call', variant: 'primary' },
{ label: 'See modules', href: '/en/module', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>

144
src/pages/en/tester.astro Normal file
View file

@ -0,0 +1,144 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
const sections = [
{
eyebrow: 'WHO WE ARE LOOKING FOR',
heading: 'A deliberately broad mix.',
items: [
'Owners juggling a tool patchwork who want to consolidate',
'Solopreneurs and micro-teams (15 people) in consulting, trades, services, manufacturing',
'Growing SMBs up to ~30 employees who want to start small today and still use the same software in 3 years',
'Online merchants with WooCommerce or Shopify — welcome but not required',
'Companies on a US cloud who are unhappy — about pricing, limits, or sovereignty concerns',
],
},
{
eyebrow: 'WHAT TESTERS GET',
heading: 'Early influence, direct line.',
items: [
'Your own SlimCore tenant on staging or production',
'Personal onboarding session (12 h) with the product lead',
'Direct line to the engineering team via the built-in feedback module',
'Several months of discounted or free access',
],
},
{
eyebrow: 'WHAT TESTERS GIVE',
heading: 'Time, real data, honest feedback.',
items: [
'12 hours per week of active product use',
'Willingness to use real data in a protected test environment',
'Structured feedback via the built-in feedback module',
'Experience review after 4 and after 12 weeks',
'Tolerance for bugs, intermediate states, and updates',
],
},
];
const expectations = [
'SlimCore is not a finished product — it is a mature platform with a clear roadmap.',
'Some modules are not yet available in tester phase 1.',
'Mobile app ships during the tester phase.',
'Tester feedback changes the roadmap. That is intentional.',
];
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
lang="en"
title="Tester program — Phase 1"
description="We are looking for solopreneurs and small teams for the tester phase. Early influence, discounted access, direct line to the team."
>
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="TESTER PROGRAM · PHASE 1"
subtitle="We are looking for solopreneurs and small teams across industries — and a few growing SMBs as well. A deliberately broad mix, because we want to see different usage patterns before we narrow."
>
You know the gaps better than any consultant.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{sections.map((section, i) => (
<section class:list={[sectionPad, i % 2 === 0 ? '' : 'bg-[var(--color-bg-surface)]']}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{section.eyebrow}
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
{section.heading}
</h2>
</div>
<ul class="md:col-span-7 flex flex-col gap-4">
{section.items.map((item) => (
<li class="flex gap-4 border-l-2 border-[var(--color-accent)] pl-4 text-[1rem] leading-[1.65] text-[var(--color-text-secondary)]">
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
))}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
WHAT TO EXPECT
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
An honest disclaimer.
</h2>
</div>
<ul class="md:col-span-7 flex flex-col gap-4 text-[var(--color-text-secondary)] italic">
{expectations.map((item) => (
<li class="text-[1rem] leading-[1.65]">{item}</li>
))}
</ul>
</div>
</div>
</section>
<section class={sectionPad}>
<div class={container}>
<blockquote class="border-l-2 border-[var(--color-accent)] pl-6 max-w-[55ch]">
<p class="font-serif text-[1.5rem] md:text-[1.75rem] font-medium leading-[1.4] text-[var(--color-text-primary)]">
“You know the gaps in your current software better than any consultant. Help us build a business software that is owned, hosted, and understood in Germany — and that does not belong to a US corporation tomorrow because of an acquisition.”
</p>
<footer class="mt-4 font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
Pascal Oelmann · digiFORMER GmbH
</footer>
</blockquote>
</div>
</section>
<section class="bg-[#0E0F14] py-20 md:py-28">
<div class={container}>
<CTABlock
inverse
eyebrow="SIGN-UP · PHASE 1"
headline="Sign-up currently runs by email or call."
body="The public sign-up form is in preparation. Until then, you will get a faster reply by writing a short note or booking a 30-minute slot."
ctas={[
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io?subject=Tester%20program%20Phase%201', variant: 'primary' },
{ label: 'Book a call', href: 'https://calendly.com/digiformer/quick-call', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>

107
src/pages/impressum.astro Normal file
View file

@ -0,0 +1,107 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
const sectionPad = 'py-16 md:py-20';
const container = 'mx-auto max-w-[820px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
title="Impressum"
description="Pflichtangaben gemäß §5 TMG für slimcore.io — ein Produkt der digiFORMER GmbH."
>
<section class="bg-[#0E0F14] pt-[112px] pb-12 md:pt-[140px] md:pb-16">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="PFLICHTANGABEN · §5 TMG"
>
Impressum
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
<section class={sectionPad}>
<div class={container}>
<div class="prose-block flex flex-col gap-10 text-[1rem] leading-[1.7] text-[var(--color-text-secondary)]">
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Anbieter</h2>
<address class="mt-3 not-italic">
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning<br />
Deutschland
</address>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Vertretungsberechtigte Geschäftsführung</h2>
<p class="mt-3">Pascal Oelmann</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Kontakt</h2>
<p class="mt-3">
Telefon: <a href="tel:+4981217671700" class="underline underline-offset-2 hover:text-[var(--color-accent)]">+49 (0) 8121 76717-0</a><br />
E-Mail: <a href="mailto:hallo@slimcore.io" class="underline underline-offset-2 hover:text-[var(--color-accent)]">hallo@slimcore.io</a>
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Registereintrag</h2>
<p class="mt-3">
Eintragung im Handelsregister.<br />
Registergericht: Amtsgericht München<br />
Registernummer: HRB 248468
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Umsatzsteuer-ID</h2>
<p class="mt-3">
Umsatzsteuer-Identifikationsnummer gemäß §27 a Umsatzsteuergesetz:<br />
DE310218819
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Verantwortlich für den Inhalt nach §55 Abs. 2 RStV</h2>
<p class="mt-3">
Pascal Oelmann<br />
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Streitschlichtung</h2>
<p class="mt-3">
Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit:
<a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener noreferrer" class="underline underline-offset-2 hover:text-[var(--color-accent)]">https://ec.europa.eu/consumers/odr/</a><br />
Unsere E-Mail-Adresse finden Sie oben im Impressum.
</p>
<p class="mt-3">
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
</div>
<div>
<h2 class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)]">Haftungsausschluss</h2>
<p class="mt-3">
Der Inhalt dieser Webseite wurde mit Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.
</p>
<p class="mt-3">
Als Diensteanbieter sind wir gemäß §7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
</p>
</div>
</div>
</div>
</section>
</BaseLayout>

297
src/pages/index.astro Normal file
View file

@ -0,0 +1,297 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import TechStrip from '@/components/marketing/TechStrip.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import NumberedItem from '@/components/marketing/NumberedItem.astro';
import ModuleGrid from '@/components/marketing/ModuleGrid.astro';
import SovereigntyBlock from '@/components/marketing/SovereigntyBlock.astro';
import RoadmapTimeline from '@/components/marketing/RoadmapTimeline.astro';
import ObjectionAnswer from '@/components/marketing/ObjectionAnswer.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
import { modules } from '@/content/module';
const stackItems = ['PostgreSQL', 'PostgREST', 'Hetzner DE', 'DSGVO', 'ZUGFeRD 2.0', 'DATEV'];
const moduleCount = modules.length;
const sovereigntyItems = [
{ label: '01 · HOSTING', body: 'Hetzner-Rechenzentren in Falkenstein und Nürnberg. Deutsches Recht, DSGVO, kein US-Anbieter im Datenfluss.' },
{ label: '02 · STACK', body: 'PostgreSQL, PostgREST, Docker. Ausschließlich Open-Source-Komponenten unter freien Lizenzen.' },
{ label: '03 · EXPORT', body: 'Volle Datenexporte in offenen Formaten — jederzeit, ohne Aufpreis, ohne Zustimmung von uns.' },
{ label: '04 · EGRESS', body: 'Optionaler Compliance-Layer mit Egress-Audit und -Proxy. Sie sehen, was Ihr Mandant sendet.' },
];
const roadmapPhases = [
{
label: 'Heute',
description: 'Was heute produktiv genutzt wird.',
current: true,
items: [
{ name: 'CRM', status: 'available' as const },
{ name: 'Lager', status: 'available' as const },
{ name: 'Bestellungen', status: 'available' as const },
{ name: 'Belege', status: 'available' as const },
{ name: 'Zahlungen', status: 'available' as const },
{ name: 'BuHa-Export (DATEV)', status: 'available' as const },
],
},
{
label: 'Q3Q4 2026',
description: 'Im aktiven Bau, kommt in den nächsten Monaten.',
items: [
{ name: 'Team-E-Mail', status: 'developing' as const },
{ name: 'Artikel', status: 'developing' as const },
{ name: 'Verkaufskanäle', status: 'developing' as const },
{ name: 'Versand', status: 'developing' as const },
{ name: 'Rechnungen (ZUGFeRD)', status: 'developing' as const },
{ name: 'Aufgaben', status: 'developing' as const },
],
},
{
label: '2027',
description: 'Feste Roadmap, Reihenfolge nach Tester-Feedback.',
items: [
{ name: 'Helpdesk', status: 'planned' as const },
{ name: 'Telefonie', status: 'planned' as const },
{ name: 'Einkauf', status: 'planned' as const },
{ name: 'Projekte', status: 'planned' as const },
{ name: 'Zeiterfassung', status: 'planned' as const },
{ name: 'Personal', status: 'planned' as const },
],
},
{
label: 'Vision',
description: 'Richtung, kein Versprechen.',
items: [
{ name: 'WhatsApp Business', status: 'vision' as const },
{ name: 'KI-Assistent', status: 'vision' as const },
],
},
];
const objections = [
{
q: 'Wir sind nur zwei. Lohnt sich das?',
a: 'Genau für Sie gebaut. Drei Module reichen zum Start — und wachsen mit, wenn Sie wachsen. Keine 27-Module-Suite, in die Sie sich erst einarbeiten müssen.',
},
{
q: 'Was, wenn wir wachsen?',
a: 'SlimCore skaliert von einer Person bis ungefähr fünfzig — dieselbe Software, mehr aktivierte Module. Kein Wechsel, keine Migration, keine zweite Schulung.',
},
{
q: 'Wir nutzen schon HubSpot.',
a: 'Behalten Sie es. SlimCore schließt die Lücke zwischen Pipeline und Belegen — also genau die Stellen, an denen HubSpot endet und Ihr Buchhalter anfängt.',
},
{
q: 'Odoo war zu komplex.',
a: 'SlimCore liefert ungefähr 80 % von Odoo, ohne den Implementierungs-Overhead. Was Sie nicht brauchen, sehen Sie nicht.',
},
{
q: 'Was, wenn ihr verschwindet?',
a: 'Voller Export in offenem Schema. Jeder PostgreSQL-Hoster führt Ihren Mandanten weiter. Kein Lock-in, auch nicht durch uns.',
},
];
const sectionPad = 'py-20 md:py-28 xl:py-32';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
title="Schlank starten. Grenzenlos wachsen."
description="Schlanke Geschäftssoftware für Solo-Selbstständige und kleine Teams. CRM, Belege, Aufgaben — modular, in Deutschland gehostet, Open Source, kein Vendor-Lock-in."
>
{/* — 1. Hero — */}
<section class="hero relative bg-[#0E0F14] pt-[112px] pb-12 md:pt-[140px] md:pb-12">
<div class={container}>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.10em] text-[#FF6B2C]">
<span aria-hidden="true">▸</span> Geschäftssoftware für Solo-Selbstständige und kleine Teams
</p>
<h1 class="hero-headline mt-6 max-w-[18ch] font-serif font-medium text-[#F5F5F0]">
Schlank starten.<br /><span class="hero-highlight">Grenzenlos</span> wachsen.
</h1>
<p class="mt-6 max-w-[540px] text-[17px] leading-[1.6] text-[rgba(245,245,240,0.75)]">
Die schlanke Geschäftssoftware für Solo-Selbstständige und kleine Teams. Sie aktivieren am Anfang nur, was Sie heute brauchen — meistens CRM, Belege und Aufgaben. Wenn aus Ihnen drei werden, dann fünfzehn, schalten Sie weitere Module zu, ohne zu wechseln.
</p>
<div class="mt-8 flex flex-wrap gap-3">
<a
href="/tester"
class="inline-flex items-center gap-1 rounded-md bg-[#FF6B2C] px-5 py-[11px] text-[13px] font-medium text-[#2A0F02] transition-colors hover:bg-[var(--color-accent-hover)]"
>
Tester werden <span aria-hidden="true">→</span>
</a>
<a
href="/module"
class="inline-flex items-center gap-1 rounded-md border-[0.5px] border-[rgba(245,245,240,0.5)] bg-transparent px-5 py-[11px] text-[13px] font-medium text-[#F5F5F0] transition-colors hover:bg-[rgba(245,245,240,0.06)]"
>
Module ansehen <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 2. Tech-Strip — */}
<TechStrip items={stackItems} variant="dark" />
{/* Sentinel: Markiert das Ende der dunklen Zone für die NavBar */}
<div data-hero-end aria-hidden="true"></div>
{/* — 3. Modul-Landschaft — */}
<section class={sectionPad}>
<div class={container}>
<SectionHeading
eyebrow={`VIER SÄULEN · ${moduleCount} MODULE`}
subtitle="Jedes Modul ist einzeln aktivierbar. Heute starten Sie mit CRM, Belegen und Aufgaben — morgen schalten Sie Versand und Team-E-Mail dazu, ohne Migration, ohne Wechsel."
>
Aktivieren Sie nur, was Sie heute brauchen.
</SectionHeading>
<div class="mt-12">
<ModuleGrid />
</div>
<div class="mt-10">
<a
href="/module"
class="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
Alle Module &amp; Status ansehen <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 4. Was uns trennt — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<SectionHeading eyebrow="WAS UNS TRENNT">
Best-Practices der großen Tools. Bedienbar wie ein modernes SaaS.
</SectionHeading>
<div class="mt-12 grid grid-cols-1 gap-10 md:grid-cols-3 md:gap-8">
<NumberedItem number="01" title="Schlank von Tag 1">
Sie aktivieren das, was Sie heute brauchen — meistens CRM, Belege und Aufgaben. Kein Onboarding-Marathon, keine 27-Module-Suite, die Sie nie ausschöpfen.
</NumberedItem>
<NumberedItem number="02" title="Wächst mit">
Wenn aus Ihnen drei werden, dann fünfzehn, bleiben Sie in derselben Software. Team-Postfach, Lager, Versand, Workflow schalten Sie zu, wenn Sie sie wirklich brauchen — ohne Migration.
</NumberedItem>
<NumberedItem number="03" title="Souverän">
In Deutschland gehostet, Open-Source-Stack, Datenexport jederzeit. Keine US-Cloud, kein Vendor-Lock-in, keine Lizenzbedingungen, die nächstes Jahr neu geschrieben werden.
</NumberedItem>
</div>
</div>
</section>
{/* — 5. Souveränität — */}
<section class={sectionPad}>
<div class={container}>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
Das große Versprechen
</p>
<div class="mt-8">
<SovereigntyBlock
headlineLines={['Ihre Daten.', 'Ihr Land.', 'Ihre Kontrolle.']}
lead="Souveränität ist bei SlimCore kein Marketing-Begriff, sondern eine Architektur-Entscheidung. Vier konkrete Punkte zeigen, was das im Alltag bedeutet."
items={sovereigntyItems}
/>
</div>
<div class="mt-12">
<a
href="/souveraenitaet"
class="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
Mehr zur Souveränität <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 6. Roadmap-Snapshot — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<SectionHeading eyebrow="ROADMAP">
Heute. Bald. Vision.
</SectionHeading>
<div class="mt-12">
<RoadmapTimeline phases={roadmapPhases} />
</div>
<div class="mt-12">
<a
href="/roadmap"
class="inline-flex items-center gap-1 text-sm font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
Detail-Roadmap <span aria-hidden="true">→</span>
</a>
</div>
</div>
</section>
{/* — 7. Schnell-Argumentarium — */}
<section class={sectionPad}>
<div class={container}>
<SectionHeading eyebrow="FÜNF EINWÄNDE, FÜNF SÄTZE">
Antworten, die Sie sonst erst im dritten Call bekommen.
</SectionHeading>
<div class="mt-12 grid grid-cols-1 gap-x-10 gap-y-8 md:grid-cols-2">
{objections.map((o) => (
<ObjectionAnswer question={o.q}>{o.a}</ObjectionAnswer>
))}
</div>
</div>
</section>
{/* — 8. Tester-Programm-Teaser — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<CTABlock
eyebrow="TESTER-PROGRAMM · PHASE 1"
headline="Sie kennen die Lücken besser als jeder Berater."
body="Helfen Sie uns, die Geschäftssoftware zu bauen, die Sie hoffentlich die nächsten zehn Jahre nutzen wollen. Im Gegenzug: früher Einfluss, vergünstigte oder kostenfreie Nutzung — und ein direkter Draht ins Team."
ctas={[
{ label: 'Tester werden', href: '/tester', variant: 'primary' },
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io', variant: 'ghost' },
]}
/>
</div>
</section>
{/* — 9. Final-CTA — */}
<section class="bg-[#0E0F14] py-20 md:py-28 xl:py-32">
<div class={container}>
<CTABlock
inverse
eyebrow="30 MINUTEN · UNVERBINDLICH"
headline="Sie schildern, wo Sie stehen — wir sagen ehrlich, ob SlimCore zu Ihrer Situation passt."
ctas={[
{ label: 'Tester werden', href: '/tester', variant: 'primary' },
{ label: 'Termin vereinbaren', href: 'https://calendly.com/digiformer/quick-call', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>
<style>
.hero-headline {
font-size: clamp(38px, 5vw, 56px);
line-height: 1.18;
letter-spacing: -0.015em;
}
.hero-highlight {
display: inline-block;
background-color: #ff6b2c;
color: #2a0f02;
padding: 0.04em 0.2em 0.08em;
line-height: 0.95;
/* +50 Gewicht gegen optische Verdünnung dunkler Schrift auf hellem Persimmon */
font-weight: 550;
vertical-align: baseline;
border-radius: 0;
}
</style>

116
src/pages/kontakt.astro Normal file
View file

@ -0,0 +1,116 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
title="Kontakt"
description="So erreichen Sie uns: per Mail, per Termin oder direkt postalisch. Kein Kontaktformular — die Tester-Anmeldung ist der primäre Kanal, alles andere geht schneller per Mail."
>
{/* — Hero — */}
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="KONTAKT"
subtitle="Wir bevorzugen die direkte Linie. Mail oder Termin geht schneller als jedes Formular — Sie schreiben, wir antworten persönlich, meistens am gleichen Tag."
>
So erreichen Sie uns.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{/* — Mail + Termin Block — */}
<section class={sectionPad}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
DREI WEGE
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
Mail, Termin oder Post.
</h2>
</div>
<div class="md:col-span-7 flex flex-col gap-10">
<div class="border-l-2 border-[var(--color-accent)] pl-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
01 · MAIL
</p>
<p class="mt-2">
<a href="mailto:hallo@slimcore.io" class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]">
hallo@slimcore.io
</a>
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
Für die meisten Anliegen der schnellste Weg. Antwort meistens am gleichen Tag, spätestens am nächsten Werktag.
</p>
</div>
<div class="border-l-2 border-[var(--color-accent)] pl-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
02 · TERMIN
</p>
<p class="mt-2">
<a
href="https://calendly.com/digiformer/quick-call"
target="_blank"
rel="noopener noreferrer"
class="font-serif text-[1.5rem] font-medium text-[var(--color-text-primary)] underline underline-offset-4 transition-colors hover:text-[var(--color-accent)]"
>
30 Minuten online buchen <span aria-hidden="true">↗</span>
</a>
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
Externer Calendly-Link. Wir erfassen keine Tracking-Daten auf slimcore.io — der Klick öffnet Calendly in einem neuen Tab. Calendly ist Übergangs-Realität, bis das SlimCore-eigene Termin-Modul fertig ist.
</p>
</div>
<div class="border-l-2 border-[var(--color-accent)] pl-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
03 · POST
</p>
<address class="mt-2 not-italic font-serif text-[1.125rem] leading-[1.5] text-[var(--color-text-primary)]">
digiFORMER GmbH<br />
Buchenstr. 5<br />
85661 Forstinning<br />
Deutschland
</address>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
Postalisch erreichbar für formale Korrespondenz. Vollständige Anschrift und Geschäftsführung im <a href="/impressum" class="underline underline-offset-2 hover:text-[var(--color-accent)]">Impressum</a>.
</p>
</div>
</div>
</div>
</div>
</section>
{/* — Hinweis: Tester ist primärer Funnel — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
FÜR TESTER-ANFRAGEN
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
Bitte direkt zur Tester-Seite.
</h2>
</div>
<div class="md:col-span-7">
<p class="text-[1.0625rem] leading-relaxed text-[var(--color-text-secondary)]">
Wenn Sie SlimCore als Tester nutzen wollen, ist die <a href="/tester" class="underline underline-offset-2 hover:text-[var(--color-accent)]">Tester-Seite</a> der richtige Einstieg. Dort steht, wer wir suchen, was Tester bekommen und was wir uns wünschen. Anmeldung läuft aktuell direkt per Mail.
</p>
</div>
</div>
</div>
</section>
</BaseLayout>

62
src/pages/module.astro Normal file
View file

@ -0,0 +1,62 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import ModuleFilter from '@/components/islands/ModuleFilter.tsx';
import {
modules,
pillarTitles,
statusLabel,
getModuleName,
getPillarTitle,
} from '@/content/module';
import { t } from '@/i18n/strings';
const lang = 'de' as const;
const s = t(lang);
const serializedModules = modules.map((m) => ({
id: m.id,
name: getModuleName(m, lang),
pillar: m.pillar,
pillarTitle: getPillarTitle(m.pillar, lang),
status: m.status,
description: m.description,
}));
const labels = {
filterStatusHeading: s.filter.filterStatusHeading,
filterPillarHeading: s.filter.filterPillarHeading,
allLabel: s.filter.allLabel,
statusLabels: statusLabel,
pillarTitles: pillarTitles,
resultsSingular: s.filter.resultsSingular,
resultsPlural: s.filter.resultsPlural,
};
---
<BaseLayout
title="Module — Was Sie aktivieren können"
description="19 Module in 4 Säulen. Was heute produktiv ist, was im Aufbau ist, was in der Roadmap steht. Aktivieren Sie heute drei, schalten Sie morgen das vierte zu."
>
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow={`MODULE · ${modules.length} GESAMT`}
subtitle="Was Sie aktivieren können — und in welcher Reife. Filtern Sie nach Status oder Säule, um schnell zu sehen, was heute produktiv ist und was in den nächsten Monaten kommt."
>
Module
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
<section class="py-16">
<div class="mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12">
<ModuleFilter client:load modules={serializedModules} labels={labels} lang={lang} />
</div>
</section>
</BaseLayout>

111
src/pages/roadmap.astro Normal file
View file

@ -0,0 +1,111 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import StatusDot from '@/components/marketing/StatusDot.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
import { modules, getStatusLabel } from '@/content/module';
import type { ModuleStatus } from '@/content/module';
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
interface Phase {
label: string;
current?: boolean;
description: string;
status: ModuleStatus;
}
const phases: Phase[] = [
{ label: 'Heute', current: true, description: 'Diese Module sind heute produktiv. Sie sind so weit, dass Sie sie als Tester direkt einsetzen.', status: 'available' },
{ label: 'Q3Q4 2026', description: 'Aktiv im Bau. Reihenfolge folgt dem Tester-Feedback.', status: 'developing' },
{ label: '2027', description: 'Feste Roadmap, Reihenfolge nach Tester-Bedarf. Beta-Phasen meist 48 Wochen pro Modul.', status: 'planned' },
{ label: 'Vision', description: 'Richtung, kein Versprechen. Wir benennen es, weil es ehrlich ist — nicht, um es zu verkaufen.', status: 'vision' },
];
---
<BaseLayout
title="Roadmap — Heute, bald, Vision"
description="Was heute produktiv ist, was Q3/Q4 2026 kommt, was 2027 ansteht, und wohin wir wollen. Komplette Modul-Liste mit aktuellem Status pro Phase."
>
{/* — Hero — */}
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="ROADMAP"
subtitle="Heute, bald, Vision. Volle Modul-Liste mit aktuellem Reife-Grad. Keine Vision wird als verfügbar verkauft, kein Verfügbares wird verschwiegen."
>
Heute. Bald. Vision.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{phases.map((phase, idx) => {
const phaseModules = modules.filter((m) => m.status === phase.status);
const surface = idx % 2 === 0 ? '' : 'bg-[var(--color-bg-surface)]';
return (
<section class:list={[sectionPad, surface]}>
<div class={container}>
<div class="flex flex-wrap items-baseline gap-3">
<span class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{phase.label}
</span>
{phase.current && (
<span class="bg-[var(--color-accent)] px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-on-accent)]">
Aktuell
</span>
)}
<span class="font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
· {phaseModules.length} {phaseModules.length === 1 ? 'Modul' : 'Module'}
</span>
</div>
<h2 class="mt-3 max-w-[20ch] font-serif text-[2rem] md:text-[2.5rem] font-medium leading-[1.15] tracking-[-0.005em] text-[var(--color-text-primary)]">
{getStatusLabel(phase.status, 'de')}
</h2>
<p class="mt-4 max-w-[60ch] text-[1.0625rem] leading-relaxed text-[var(--color-text-secondary)]">
{phase.description}
</p>
<ul class="mt-10 grid grid-cols-1 gap-4 md:grid-cols-2">
{phaseModules.map((m) => (
<li class="flex flex-col gap-2 border-l-2 border-[var(--color-border-strong)] pl-4">
<div class="flex items-baseline gap-3">
<h3 class="font-serif text-[1.125rem] font-medium text-[var(--color-text-primary)]">
{m.name}
</h3>
<span class="font-mono text-[11px] uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
{m.pillar} · {m.pillarTitle}
</span>
<StatusDot status={m.status} />
</div>
<p class="text-[0.9375rem] leading-[1.6] text-[var(--color-text-secondary)]">
{m.description}
</p>
</li>
))}
</ul>
</div>
</section>
);
})}
<section class="bg-[#0E0F14] py-20 md:py-28">
<div class={container}>
<CTABlock
inverse
eyebrow="ROADMAP-FEEDBACK"
headline="Tester verändern die Reihenfolge."
body="Welches Modul sollte 2026 vor welchem kommen? Welche Funktion fehlt komplett? Wir sortieren die Roadmap nach dem, was unsere Tester wirklich brauchen."
ctas={[
{ label: 'Tester werden', href: '/tester', variant: 'primary' },
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io?subject=Roadmap-Feedback', variant: 'ghost' },
]}
/>
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,176 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import NumberedItem from '@/components/marketing/NumberedItem.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
const stack = [
{ name: 'PostgreSQL', license: 'PostgreSQL License', vendor: 'OSS', purpose: 'Datenbank' },
{ name: 'PostgREST', license: 'MIT', vendor: 'OSS', purpose: 'API-Schicht' },
{ name: 'Traefik', license: 'MIT', vendor: 'OSS', purpose: 'Reverse-Proxy + TLS' },
{ name: 'Docker', license: 'Apache 2.0', vendor: 'OSS', purpose: 'Container-Runtime' },
{ name: 'React', license: 'MIT', vendor: 'OSS (Meta)', purpose: 'UI-Framework' },
{ name: 'Astro', license: 'MIT', vendor: 'OSS', purpose: 'Marketing-Site SSG' },
];
const noUSItems = [
{ label: 'AUTH', body: 'Auth-Layer auf PostgreSQL-Basis. Zitadel (CH) als perspektivisches Ziel für föderierte Identitäten.' },
{ label: 'MAIL', body: 'Brevo (FR) als Transactional-Mail-Provider. Keine US-Anbieter im Mail-Flow.' },
{ label: 'STORAGE', body: 'Garage als selbst gehosteter Object-Storage-Cluster, S3-API-kompatibel ohne S3.' },
{ label: 'PAYMENTS', body: 'Vivid Money (LU/DE) als Geschäftskonto. Mollie (NL) als Subscription-Billing in Phase 3.' },
];
---
<BaseLayout
title="Souveränität — wie SlimCore das löst"
description="Hetzner-Rechenzentren, Open-Source-Stack, kein US-Anbieter im Datenfluss, voller Datenexport in offenen Formaten. Keine Marketing-Floskeln — vier konkrete Ebenen."
>
{/* — Hero — */}
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="DAS GROSSE VERSPRECHEN"
subtitle="Souveränität ist bei SlimCore keine Marketing-Floskel, sondern eine Architektur-Entscheidung. Hier ist, was das im Alltag bedeutet — Ebene für Ebene, nüchtern beschrieben."
>
Ihre Daten. Ihr Land. Ihre Kontrolle.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{/* — 01 — Warum das jetzt zählt — */}
<section class={sectionPad}>
<div class={container}>
<NumberedItem number="01" title="Warum das jetzt zählt.">
<p class="mt-2">
CLOUD Act, Schrems II, geopolitische Unsicherheit: Die rechtliche Lage für US-Cloud-Anbieter in Europa hat sich in den letzten Jahren verhärtet, nicht gelockert. Wer Kundendaten heute in eine US-Cloud schreibt, tut das mit dem Wissen, dass eine US-Behörde rechtlich Zugriff verlangen kann — auch dann, wenn die Daten in einem Frankfurter Rechenzentrum liegen.
</p>
<p class="mt-4">
Das ist kein Verschwörungs-Argument. Es ist die Lesart der Schrems-II-Entscheidung des EuGH. Wir nehmen sie ernst, weil unsere Tester sie ernst nehmen.
</p>
</NumberedItem>
</div>
</section>
{/* — 02 — Hosting — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<NumberedItem number="02" title="Hosting in Deutschland — kein Marketing.">
<p class="mt-2">
Alle SlimCore-Mandanten laufen auf Hetzner-Servern in Deutschland. Standorte: Falkenstein und Nürnberg. Vertragspartner: Hetzner Online GmbH, Gunzenhausen — eine GmbH nach deutschem Recht, ohne US-Mutter, ohne Außenstellen außerhalb der EU.
</p>
<p class="mt-4">
Kein „in der EU verarbeitet". Wirklich auf deutschem Boden, in Rechenzentren mit deutschen Datenschutzbehörden zuständig. DSGVO ist Mindestanforderung — kein Verkaufsargument.
</p>
</NumberedItem>
</div>
</section>
{/* — 03 — Open-Source-Stack — */}
<section class={sectionPad}>
<div class={container}>
<NumberedItem number="03" title="Open-Source-Stack, vollständig.">
<p class="mt-2 mb-8">
Jede Komponente in unserem Stack ist quelloffen unter freier Lizenz. Wir nutzen keine proprietäre Datenbank, keinen geschlossenen Auth-Provider, keinen Closed-Source-Application-Server. Das hat zwei Konsequenzen: erstens können Sie alles selbst hosten, zweitens kann niemand Ihnen morgen die Lizenz entziehen.
</p>
<div class="overflow-hidden border border-[var(--color-border)] bg-[var(--color-bg-surface)]">
<table class="w-full text-left text-[0.9375rem]">
<thead>
<tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-base)]">
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Komponente</th>
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Lizenz</th>
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Anbieter</th>
<th class="px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">Zweck</th>
</tr>
</thead>
<tbody>
{stack.map((row, i) => (
<tr class:list={[i < stack.length - 1 ? 'border-b border-[var(--color-border)]' : '']}>
<td class="px-4 py-3 font-mono text-[var(--color-text-primary)]">{row.name}</td>
<td class="px-4 py-3 text-[var(--color-text-secondary)]">{row.license}</td>
<td class="px-4 py-3 text-[var(--color-text-secondary)]">{row.vendor}</td>
<td class="px-4 py-3 text-[var(--color-text-secondary)]">{row.purpose}</td>
</tr>
))}
</tbody>
</table>
</div>
</NumberedItem>
</div>
</section>
{/* — 04 — Keine US-Abhängigkeiten — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<NumberedItem number="04" title="Keine US-Abhängigkeiten — überall geprüft.">
<p class="mt-2 mb-8">
Die harte Regel im Markensystem: Jeder Vendor im kritischen Daten- oder Anwendungspfad hat seine Hauptniederlassung in DE, CH, AT oder EU und betreibt seine Rechenzentren in der EU. Keine Ausnahmen. Auch keine US-Töchter, weil die Konzern-Mutter die CLOUD-Act-Exposition nicht aufhebt.
</p>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{noUSItems.map((item) => (
<div class="border-l-2 border-[var(--color-accent)] pl-4">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{item.label}
</p>
<p class="mt-2 text-[0.9375rem] leading-[1.6] text-[var(--color-text-primary)]">
{item.body}
</p>
</div>
))}
</div>
</NumberedItem>
</div>
</section>
{/* — 05 — Kein Vendor-Lock-in — */}
<section class={sectionPad}>
<div class={container}>
<NumberedItem number="05" title="Kein Vendor-Lock-in — Sie behalten Ihre Daten.">
<p class="mt-2">
Sie können Ihren kompletten Mandanten jederzeit als ZIP exportieren — strukturierte Daten als JSON oder CSV, Belege als PDF, Anhänge in Originalformaten. Keine proprietären Container, keine Bezahl-Schranke. Das Schema ist offen dokumentiert. Jeder PostgreSQL-Hoster führt Ihre Daten weiter, falls Sie einmal von uns weg wollen.
</p>
<p class="mt-4">
BuHa-Exports erzeugen wir in Standard-Formaten: DATEV-CSV (heute produktiv), sevDesk-API und Lexware-API in Vorbereitung. Sie wechseln Ihren Steuerberater, ohne uns zu verlassen.
</p>
</NumberedItem>
</div>
</section>
{/* — 06 — Optionaler Compliance-Layer — */}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<NumberedItem number="06" title="Optional: Egress-Audit und Egress-Proxy.">
<p class="mt-2">
Für regulierte Branchen oder Unternehmen mit eigenen Compliance-Anforderungen bieten wir einen optionalen Compliance-Layer: Egress-Audit (was sendet Ihr Mandant nach außen, an wen, wann) plus Egress-Proxy (kontrollierte Outbound-Verbindungen, mit Whitelist).
</p>
<p class="mt-4">
Das ist keine Standard-Funktion, sondern für Tester, die Souveränität nicht nur als Versprechen, sondern als prüfbare Eigenschaft brauchen.
</p>
</NumberedItem>
</div>
</section>
{/* — Final CTA — */}
<section class="bg-[#0E0F14] py-20 md:py-28">
<div class={container}>
<CTABlock
inverse
eyebrow="WEITER"
headline="Souveränität ist Architektur, nicht Marketing."
body="Wenn Sie Fragen haben, wie konkret SlimCore die Souveränitäts-Anforderungen Ihres Unternehmens erfüllt — schreiben Sie kurz. 30 Minuten reichen meistens."
ctas={[
{ label: 'Termin vereinbaren', href: 'https://calendly.com/digiformer/quick-call', variant: 'primary' },
{ label: 'Module ansehen', href: '/module', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>

143
src/pages/tester.astro Normal file
View file

@ -0,0 +1,143 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import SectionHeading from '@/components/marketing/SectionHeading.astro';
import CTABlock from '@/components/marketing/CTABlock.astro';
const sections = [
{
eyebrow: 'WER WIRD GESUCHT',
heading: 'Ein bewusst breites Spektrum.',
items: [
'Inhaber:innen, die heute ein Tool-Sammelsurium nutzen und es ordnen wollen',
'Solo-Selbstständige und Mikroteams (15 Personen) aus Beratung, Handwerk, Dienstleistung, Manufaktur',
'Wachsende KMU bis ~30 Mitarbeitende, die heute klein starten und in 3 Jahren noch dieselbe Software nutzen wollen',
'Online-Händler mit WooCommerce oder Shopify — willkommen, aber kein Muss',
'Unternehmen, die heute eine US-Cloud nutzen und unzufrieden sind — wegen Preisen, Limits oder Souveränitäts-Bedenken',
],
},
{
eyebrow: 'WAS TESTER BEKOMMEN',
heading: 'Früher Einfluss, direkter Draht.',
items: [
'Eigene SlimCore-Instanz (Mandant) auf Staging oder Produktion',
'Persönlicher Onboarding-Termin (12 h) mit dem Produktverantwortlichen',
'Direkter Draht ins Entwicklungsteam über das eingebaute Feedback-Modul',
'Mehrere Monate vergünstigter oder kostenfreier Nutzung',
],
},
{
eyebrow: 'WAS TESTER GEBEN',
heading: 'Zeit, Daten, ehrliches Feedback.',
items: [
'12 Stunden pro Woche aktiv mit dem Produkt arbeiten',
'Bereitschaft, echte Daten in geschützter Testumgebung zu nutzen',
'Strukturiertes Feedback über das eingebaute Feedback-Modul',
'Erfahrungs-Gespräch nach 4 und nach 12 Wochen',
'Toleranz für Bugs, Zwischenstände und Updates',
],
},
];
const expectations = [
'SlimCore ist kein fertiges Produkt — eine reife Plattform mit klarer Roadmap.',
'Manche Module sind in Tester-Phase 1 noch nicht verfügbar.',
'Mobile-App kommt im Verlauf der Tester-Phase.',
'Tester-Feedback verändert die Roadmap. Das ist gewollt.',
];
const sectionPad = 'py-20 md:py-28';
const container = 'mx-auto max-w-[1100px] px-6 md:px-10 xl:px-12';
---
<BaseLayout
title="Tester-Programm — Phase 1"
description="Wir suchen Solo-Selbstständige und kleine Teams für die Tester-Phase. Früher Einfluss, vergünstigte Nutzung, direkter Draht ins Team."
>
<section class="bg-[#0E0F14] pt-[112px] pb-16 md:pt-[140px] md:pb-20">
<div class={container}>
<SectionHeading
as="h1"
inverse
eyebrowPrefix="▸"
eyebrow="TESTER-PROGRAMM · PHASE 1"
subtitle="Wir suchen Solo-Selbstständige und kleine Teams aus diversen Branchen — und auch ein paar wachsende KMU. Ein bewusst breites Spektrum, weil wir verschiedene Nutzungsmuster sehen wollen, bevor wir verengen."
>
Sie kennen die Lücken besser als jeder Berater.
</SectionHeading>
</div>
</section>
<div data-hero-end aria-hidden="true"></div>
{sections.map((section, i) => (
<section class:list={[sectionPad, i % 2 === 0 ? '' : 'bg-[var(--color-bg-surface)]']}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{section.eyebrow}
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
{section.heading}
</h2>
</div>
<ul class="md:col-span-7 flex flex-col gap-4">
{section.items.map((item) => (
<li class="flex gap-4 border-l-2 border-[var(--color-accent)] pl-4 text-[1rem] leading-[1.65] text-[var(--color-text-secondary)]">
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
))}
<section class={`${sectionPad} bg-[var(--color-bg-surface)]`}>
<div class={container}>
<div class="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-16">
<div class="md:col-span-5">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
WORAUF EINSTELLEN
</p>
<h2 class="mt-4 max-w-[18ch] font-serif text-[1.75rem] md:text-[2rem] font-medium leading-[1.2] tracking-[-0.005em] text-[var(--color-text-primary)]">
Ein ehrlicher Disclaimer.
</h2>
</div>
<ul class="md:col-span-7 flex flex-col gap-4 text-[var(--color-text-secondary)] italic">
{expectations.map((item) => (
<li class="text-[1rem] leading-[1.65]">{item}</li>
))}
</ul>
</div>
</div>
</section>
<section class={sectionPad}>
<div class={container}>
<blockquote class="border-l-2 border-[var(--color-accent)] pl-6 max-w-[55ch]">
<p class="font-serif text-[1.5rem] md:text-[1.75rem] font-medium leading-[1.4] text-[var(--color-text-primary)]">
„Sie kennen die Lücken in Ihrer aktuellen Software besser als jeder Berater. Helfen Sie uns, eine Geschäftssoftware zu bauen, die in Deutschland gehört, gehostet und verstanden wird — und die nicht morgen einem US-Konzern gehört, weil eine Übernahme passiert ist."
</p>
<footer class="mt-4 font-mono text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
Pascal Oelmann · digiFORMER GmbH
</footer>
</blockquote>
</div>
</section>
<section class="bg-[#0E0F14] py-20 md:py-28">
<div class={container}>
<CTABlock
inverse
eyebrow="ANMELDUNG · PHASE 1"
headline="Anmeldung läuft aktuell per Mail oder Termin."
body="Das öffentliche Anmelde-Formular ist in Vorbereitung. Bis dahin bekommen Sie schneller eine Antwort, wenn Sie kurz schreiben oder einen 30-Minuten-Slot buchen."
ctas={[
{ label: 'hallo@slimcore.io', href: 'mailto:hallo@slimcore.io?subject=Tester-Programm%20Phase%201', variant: 'primary' },
{ label: 'Termin vereinbaren', href: 'https://calendly.com/digiformer/quick-call', variant: 'secondary' },
]}
/>
</div>
</section>
</BaseLayout>

128
src/styles/global.css Normal file
View file

@ -0,0 +1,128 @@
@import "tailwindcss";
/* SlimCore Schriften (familienweit, Brand-System §4.1)
Headlines + Body: Outfit (geometrisch-modern, single-font-Setup)
Technische Marker (Eyebrows, Stack-Strip, Wortmarke): JetBrains Mono */
@font-face {
font-family: "Outfit";
src: url("/fonts/outfit-variable.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
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+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Outfit";
src: url("/fonts/outfit-variable-latin-ext.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
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;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-variable.woff2") format("woff2");
font-weight: 100 800;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-variable-latin-ext.woff2") format("woff2");
font-weight: 100 800;
font-style: normal;
font-display: swap;
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;
}
@theme inline {
/* Font Stacks
--font-serif zeigt auf Outfit (kein Serif mehr im SlimCore-Setup), Alias bleibt für Tailwind-Kompatibilität bestehen */
--font-serif: "Outfit", system-ui, sans-serif;
--font-sans: "Outfit", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
/* — SlimCore Accent (Electric Persimmon) — */
--color-accent: oklch(0.71 0.22 38);
--color-accent-hover: oklch(0.62 0.23 38);
--color-accent-soft: oklch(0.71 0.22 38 / 0.12);
--color-text-on-accent: #2A0F02;
/* — Surfaces (familienweit) — */
--color-bg-base: oklch(0.99 0.005 90);
--color-bg-surface: oklch(1.00 0 0);
--color-bg-inverse: oklch(0.18 0.01 270);
/* — Text — */
--color-text-primary: oklch(0.20 0.01 270);
--color-text-secondary: oklch(0.45 0.01 270);
/* Auf 0.50 angehoben gegenüber Brand-System (0.60), damit 11px-Eyebrows WCAG-AA erreichen.
Synchronisierung des Brand-Systems empfohlen, sobald @digiformer/design-tokens existiert. */
--color-text-tertiary: oklch(0.50 0.01 270);
/* — Borders — */
--color-border: oklch(0.90 0.005 90);
--color-border-strong: oklch(0.80 0.005 90);
/* — Type Scale — */
--text-eyebrow: 0.6875rem;
--text-body-sm: 0.8125rem;
--text-body: 1rem;
--text-body-lg: 1.0625rem;
--text-h3: 1.25rem;
--text-h2: 1.5rem;
--text-h1: 2rem;
--text-display: 3rem;
}
html {
font-family: var(--font-sans);
color: var(--color-text-primary);
background-color: var(--color-bg-base);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3 {
font-family: var(--font-sans);
font-weight: 500;
line-height: 1.15;
letter-spacing: -0.01em;
text-wrap: balance;
}
/* — Status-Glyph (synchron mit StatusDot.astro, für React-Islands die kein Astro-Component nutzen können) — */
.dot {
--dot-color: var(--color-text-primary);
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-block;
}
.dot-available {
background-color: var(--dot-color);
}
.dot-developing {
background-color: var(--dot-color);
opacity: 0.45;
}
.dot-planned {
background: transparent;
border: 1.5px solid var(--dot-color);
opacity: 0.7;
}
.dot-vision {
background: transparent;
border: 1.5px dashed var(--dot-color);
opacity: 0.6;
}

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}