big upgrade

This commit is contained in:
2026-05-10 12:19:58 +02:00
parent 5bd2019fc6
commit f7dd547f8d
38 changed files with 2363 additions and 329 deletions

View File

@@ -0,0 +1 @@
{"specId": "19abeaa5-fd7a-4c4e-9557-d63c62f2e8e1", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -0,0 +1,263 @@
# Design-Dokument System-Theme-Erkennung (Light/Dark Mode)
## Übersicht
Dieses Feature integriert die bereits installierte Bibliothek `next-themes` (v0.3.0) in die bestehende React + Vite + Tailwind + shadcn/ui-Webseite, um eine automatische Erkennung des Betriebssystem-Themes (hell/dunkel) sowie manuelles Umschalten zu ermöglichen.
Aktuell definiert `:root` in `src/index.css` das dunkle Farbschema. Es existiert zwar ein `.dark`-Block, dieser wird aber nicht aktiv genutzt. Die Tailwind-Konfiguration verwendet bereits `darkMode: ["class"]`, was perfekt mit `next-themes` zusammenarbeitet.
Kernentscheidungen:
- `next-themes` wird als ThemeProvider eingesetzt (bereits als Dependency vorhanden, `attribute: "class"` für Tailwind-Kompatibilität)
- Das aktuelle `:root`-Farbschema wird zum Light Theme umstrukturiert, ein neues Dark Theme wird unter `.dark` definiert
- Ein Theme-Toggle-Button wird im Header platziert (Desktop und Mobile)
- Ein Inline-Script in `index.html` verhindert Theme-Flackern beim Laden (FOUC-Prevention)
## Architektur
```mermaid
graph TD
A[index.html Inline-Script] -->|Setzt class auf html| B[html-Element]
B --> C[ThemeProvider next-themes]
C --> D[App.tsx]
D --> E[Header mit ThemeToggle]
D --> F[Alle Seiten & Komponenten]
G[localStorage theme] <-->|Lesen/Schreiben| C
H[prefers-color-scheme] -->|System-Erkennung| C
I[index.css :root Light] --> F
J[index.css .dark Dark] --> F
```
### Datenfluss
1. Beim Laden der Seite liest ein Inline-Script in `index.html` den `localStorage`-Wert (`theme`) aus. Falls keiner vorhanden ist, wird `prefers-color-scheme` geprüft. Die entsprechende CSS-Klasse (`dark` oder keine) wird auf `<html>` gesetzt noch bevor React rendert.
2. `ThemeProvider` aus `next-themes` übernimmt die Verwaltung im React-Baum. Er synchronisiert den Zustand mit `localStorage` und reagiert auf Änderungen der System-Einstellung via `matchMedia`-Listener.
3. Der `ThemeToggle`-Button im Header nutzt den `useTheme()`-Hook, um zwischen `light`, `dark` und `system` zu wechseln.
4. Alle Komponenten nutzen weiterhin `hsl(var(--...))` CSS-Variablen der Wechsel erfolgt rein über CSS-Klassen.
## Komponenten und Schnittstellen
### 1. ThemeProvider-Wrapper (`src/components/ThemeProvider.tsx`)
Dünner Wrapper um `ThemeProvider` aus `next-themes`:
```typescript
interface ThemeProviderProps {
children: React.ReactNode;
defaultTheme?: string; // Standard: "system"
storageKey?: string; // Standard: "theme"
enableSystem?: boolean; // Standard: true
disableTransitionOnChange?: boolean; // Standard: false
}
```
Konfiguration:
- `attribute="class"` Tailwind-kompatibel
- `defaultTheme="system"` Erstbesucher erhalten System-Theme
- `enableSystem={true}` Automatische Erkennung aktiv
- `storageKey="theme"` localStorage-Schlüssel
Wird in `App.tsx` als äußerster Wrapper um den gesamten Komponentenbaum eingebunden.
### 2. ThemeToggle-Komponente (`src/components/ThemeToggle.tsx`)
Button-Komponente mit Dropdown-Menü (shadcn/ui `DropdownMenu`):
```typescript
interface ThemeToggleProps {
className?: string;
}
```
Funktionalität:
- Zeigt ein Icon basierend auf dem aktuellen Theme (Sun, Moon, Monitor aus `lucide-react`)
- Dropdown mit drei Optionen: „Hell", „Dunkel", „System"
- Nutzt `useTheme()` Hook für `theme`, `setTheme`, `resolvedTheme`
- `aria-label` beschreibt den aktuellen Zustand
- Tastatur-bedienbar (Tab, Enter/Space)
- Mounted-Check verhindert Hydration-Mismatch (Icon wird erst nach Mount gerendert)
### 3. Anpassungen am Header (`src/components/Header.tsx`)
- ThemeToggle wird im Desktop-NavBody neben dem „Kontakt"-Button eingefügt
- ThemeToggle wird im Mobile-Nav-Header neben dem Hamburger-Menü eingefügt
- Positionierung: rechts, vor dem Kontakt-Button (Desktop) bzw. links vom Toggle-Icon (Mobile)
### 4. Inline-Script in `index.html`
Ein `<script>`-Block im `<head>`, der vor dem React-Bundle ausgeführt wird:
```javascript
// Liest localStorage("theme") aus
// Falls "system" oder nicht vorhanden: prüft prefers-color-scheme
// Setzt class="dark" auf <html> wenn Dark Mode aktiv
```
Dieses Script verhindert das sichtbare Flackern (FOUC) beim Seitenaufruf.
### 5. CSS-Variablen-Umstrukturierung (`src/index.css`)
- `:root` → Light Theme (helle Hintergründe, dunkle Texte)
- `.dark` → Dark Theme (das aktuelle `:root`-Farbschema wird hierhin verschoben)
- Alle bestehenden Komponentenstile (`.glass-nav`, `.card-minimal`, `.project-card`, `.btn`, etc.) werden auf Theme-Kompatibilität geprüft und ggf. angepasst
- Hardcodierte Farbwerte in Komponentenstilen werden durch CSS-Variablen ersetzt
### Komponentendiagramm
```mermaid
graph LR
subgraph App.tsx
TP[ThemeProvider]
TP --> Router
end
subgraph Header
TT[ThemeToggle]
TT -->|useTheme| TP
end
subgraph CSS
LT[":root Light Theme"]
DT[".dark Dark Theme"]
end
TP -->|class auf html| CSS
```
## Datenmodelle
### Theme-Zustand
```typescript
type ThemeValue = "light" | "dark" | "system";
// Von next-themes bereitgestellt via useTheme()
interface ThemeState {
theme: ThemeValue; // Gewählte Präferenz ("light" | "dark" | "system")
resolvedTheme: "light" | "dark"; // Tatsächlich angewendetes Theme
setTheme: (theme: ThemeValue) => void;
systemTheme: "light" | "dark"; // Aktuelle OS-Einstellung
}
```
### localStorage-Schema
| Schlüssel | Wert | Beschreibung |
|-----------|------|--------------|
| `theme` | `"light"` \| `"dark"` \| `"system"` | Gespeicherte Benutzerpräferenz |
### CSS-Custom-Properties (Light Theme neu zu erstellen)
```css
:root {
--background: 0 0% 100%; /* Weiß */
--foreground: 0 0% 9%; /* Fast Schwarz */
--card: 0 0% 98%; /* Sehr helles Grau */
--card-foreground: 0 0% 9%;
--primary: 198 93% 42%; /* Cyan-Blau (konsistent) */
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 94%; /* Helles Grau */
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 94%;
--muted-foreground: 0 0% 40%;
--accent: 0 0% 94%;
--accent-foreground: 0 0% 9%;
--border: 0 0% 88%;
--input: 0 0% 88%;
--ring: 198 93% 42%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 98%;
/* ... weitere Token analog */
}
```
### CSS-Custom-Properties (Dark Theme bestehendes Schema)
```css
.dark {
--background: 0 0% 0%; /* Schwarz (aktuelles :root) */
--foreground: 0 0% 92%;
--card: 0 0% 6%;
--card-foreground: 0 0% 92%;
--primary: 180 40% 50%;
--primary-foreground: 0 0% 3%;
/* ... restliche aktuelle :root-Werte */
}
```
## Korrektheitseigenschaften (Correctness Properties)
*Eine Korrektheitseigenschaft ist ein Merkmal oder Verhalten, das über alle gültigen Ausführungen eines Systems hinweg gelten soll im Wesentlichen eine formale Aussage darüber, was das System tun soll. Eigenschaften bilden die Brücke zwischen menschenlesbaren Spezifikationen und maschinell überprüfbaren Korrektheitsgarantien.*
### Property 1: System-Theme-Auflösung
*Für jede* beliebige System-Theme-Einstellung (hell oder dunkel) gilt: Wenn die Theme-Präferenz auf „system" steht (oder keine Präferenz gespeichert ist), dann soll das aufgelöste Theme (`resolvedTheme`) dem aktuellen System-Theme entsprechen.
**Validates: Requirements 1.1, 1.2, 1.3, 2.5**
### Property 2: Explizite Theme-Wahl überschreibt System
*Für jede* beliebige System-Theme-Einstellung und *für jede* explizite Theme-Wahl (`"light"` oder `"dark"`) gilt: Das aufgelöste Theme (`resolvedTheme`) soll immer der expliziten Wahl entsprechen, unabhängig vom System-Theme.
**Validates: Requirements 2.3, 2.4**
### Property 3: Persistenz-Round-Trip
*Für jeden* gültigen Theme-Wert (`"light"`, `"dark"`, `"system"`) gilt: Wenn der Wert über `setTheme()` gesetzt wird, dann soll der im `localStorage` gespeicherte Wert identisch sein, und beim erneuten Laden soll dieser gespeicherte Wert korrekt als aktive Präferenz wiederhergestellt werden.
**Validates: Requirements 3.1, 3.2**
## Fehlerbehandlung
| Szenario | Verhalten |
|----------|-----------|
| `localStorage` nicht verfügbar (blockiert, privater Modus) | ThemeProvider fällt auf System-Theme-Erkennung zurück. Kein Fehler wird geworfen. Die Seite funktioniert normal, nur die Persistenz entfällt. |
| `prefers-color-scheme` nicht unterstützt (ältere Browser) | `next-themes` fällt auf den `defaultTheme` zurück (`"system"` → wird als `"light"` aufgelöst). |
| Ungültiger Wert im `localStorage` | `next-themes` ignoriert ungültige Werte und fällt auf `defaultTheme` zurück. |
| JavaScript deaktiviert | Das Inline-Script in `index.html` wird nicht ausgeführt. Die Seite zeigt das Standard-CSS (Light Theme in `:root`). Kein Theme-Toggle verfügbar, aber die Seite bleibt nutzbar. |
| Hydration-Mismatch (SSR/SSG) | Nicht relevant für Vite-SPA. Der Mounted-Check im ThemeToggle verhindert trotzdem Icon-Flackern. |
## Teststrategie
### Property-Based Tests (fast-check)
Bibliothek: `fast-check` (für TypeScript/Vitest)
Konfiguration:
- Minimum 100 Iterationen pro Property-Test
- Jeder Test referenziert die zugehörige Design-Property
- Tag-Format: **Feature: system-theme-detection, Property {number}: {property_text}**
Die drei Korrektheitseigenschaften werden als Property-Based Tests implementiert:
1. **Property 1** Theme-Auflösungslogik bei „system"-Präferenz: Generiere zufällige System-Theme-Werte, setze Präferenz auf „system", prüfe `resolvedTheme === systemTheme`.
2. **Property 2** Explizite Wahl überschreibt System: Generiere zufällige Kombinationen aus System-Theme und expliziter Wahl, prüfe `resolvedTheme === expliziteWahl`.
3. **Property 3** Persistenz-Round-Trip: Generiere zufällige gültige Theme-Werte, speichere via `setTheme()`, lese aus `localStorage`, prüfe Gleichheit. Simuliere Neustart, prüfe ob Wert wiederhergestellt wird.
### Unit Tests (Vitest + React Testing Library)
Ergänzend zu den Property-Tests werden folgende Example-basierte Tests geschrieben:
- **ThemeToggle-Rendering**: Button ist im Header vorhanden (Req. 2.1)
- **Dropdown-Optionen**: Drei Optionen (Hell, Dunkel, System) sind vorhanden (Req. 2.2)
- **Icon-Zuordnung**: Korrektes Icon für jeden Theme-Zustand (Req. 2.6)
- **CSS-Token-Vollständigkeit**: Alle erforderlichen Custom Properties sind im Light Theme definiert (Req. 4.1)
- **Kontrastverhältnisse**: Schlüssel-Farbpaare erfüllen WCAG-AA (Req. 4.2)
- **Tastatur-Bedienbarkeit**: Tab-Navigation und Enter/Space funktionieren (Req. 6.1)
- **aria-label**: Beschreibt den aktuellen Zustand korrekt (Req. 6.2)
- **aria-live**: Zustandswechsel wird kommuniziert (Req. 6.3)
### Edge-Case Tests
- **localStorage blockiert**: Fallback auf System-Theme ohne Fehler (Req. 3.3)
### Integrationstests
- **matchMedia-Listener**: Simuliere OS-Theme-Wechsel, prüfe ob resolvedTheme sich aktualisiert (Req. 1.4)
### Nicht automatisiert testbar
- Visuell korrekte Darstellung der Komponentenstile im Light Theme (Req. 4.3) → Manuelle visuelle Prüfung
- Kein sichtbares Flackern beim Laden (Req. 5.2) → Manuelle visuelle Prüfung

View File

@@ -0,0 +1,80 @@
# Anforderungsdokument System-Theme-Erkennung (Light/Dark Mode)
## Einleitung
Die Webseite (React + Tailwind + shadcn/ui) verwendet aktuell ein fest eingestelltes dunkles Farbschema. Es soll eine automatische Erkennung der Betriebssystem-Einstellung (Light/Dark Mode) eingeführt werden, sodass die Webseite das passende Theme anzeigt. Zusätzlich soll der Benutzer das Theme manuell umschalten können. Die Präferenz wird im Browser gespeichert, damit sie bei erneutem Besuch erhalten bleibt.
## Glossar
- **Theme_Provider**: Die React-Kontextkomponente, die das aktuelle Theme verwaltet und an alle Kindkomponenten weitergibt. Basiert auf `next-themes`.
- **Theme_Toggle**: Die UI-Komponente (Button), mit der der Benutzer manuell zwischen Light Mode, Dark Mode und System-Automatik wechseln kann.
- **System_Theme**: Die vom Betriebssystem des Benutzers bevorzugte Farbeinstellung (hell oder dunkel), ermittelt über die CSS-Media-Query `prefers-color-scheme`.
- **Light_Theme**: Das helle Farbschema mit CSS-Custom-Properties für helle Hintergründe und dunkle Texte.
- **Dark_Theme**: Das dunkle Farbschema mit CSS-Custom-Properties für dunkle Hintergründe und helle Texte (aktuell als `:root`-Standard definiert).
- **Theme_Präferenz**: Die vom Benutzer gewählte Einstellung (`light`, `dark` oder `system`), gespeichert im `localStorage`.
## Anforderungen
### Anforderung 1: Automatische Erkennung des System-Themes
**User Story:** Als Besucher möchte ich, dass die Webseite automatisch erkennt, ob mein Betriebssystem auf hell oder dunkel eingestellt ist, damit die Seite sofort zum Erscheinungsbild meines Systems passt.
#### Akzeptanzkriterien
1. WHEN ein Benutzer die Webseite zum ersten Mal besucht und keine gespeicherte Theme_Präferenz vorhanden ist, THE Theme_Provider SHALL das System_Theme über die Media-Query `prefers-color-scheme` ermitteln und das entsprechende Farbschema anwenden.
2. WHILE das System_Theme auf „dunkel" eingestellt ist und die Theme_Präferenz auf „system" steht, THE Theme_Provider SHALL das Dark_Theme anwenden.
3. WHILE das System_Theme auf „hell" eingestellt ist und die Theme_Präferenz auf „system" steht, THE Theme_Provider SHALL das Light_Theme anwenden.
4. WHEN der Benutzer die Betriebssystem-Einstellung von hell auf dunkel oder umgekehrt ändert und die Theme_Präferenz auf „system" steht, THE Theme_Provider SHALL das angezeigte Theme innerhalb von 1 Sekunde aktualisieren, ohne dass die Seite neu geladen werden muss.
### Anforderung 2: Manuelles Umschalten des Themes
**User Story:** Als Besucher möchte ich das Theme manuell zwischen Light Mode, Dark Mode und System-Automatik umschalten können, damit ich unabhängig von meiner Systemeinstellung das bevorzugte Erscheinungsbild wählen kann.
#### Akzeptanzkriterien
1. THE Theme_Toggle SHALL im Header der Webseite sichtbar und erreichbar platziert sein.
2. WHEN der Benutzer den Theme_Toggle betätigt, THE Theme_Toggle SHALL die drei Optionen „Light", „Dark" und „System" zur Auswahl anbieten.
3. WHEN der Benutzer die Option „Light" auswählt, THE Theme_Provider SHALL das Light_Theme sofort anwenden.
4. WHEN der Benutzer die Option „Dark" auswählt, THE Theme_Provider SHALL das Dark_Theme sofort anwenden.
5. WHEN der Benutzer die Option „System" auswählt, THE Theme_Provider SHALL das Theme gemäß dem aktuellen System_Theme anwenden.
6. THE Theme_Toggle SHALL ein Icon anzeigen, das den aktuellen Theme-Zustand visuell darstellt (Sonne für Light, Mond für Dark, Monitor für System).
### Anforderung 3: Persistenz der Theme-Auswahl
**User Story:** Als wiederkehrender Besucher möchte ich, dass meine Theme-Auswahl gespeichert wird, damit ich beim nächsten Besuch nicht erneut umschalten muss.
#### Akzeptanzkriterien
1. WHEN der Benutzer eine Theme_Präferenz über den Theme_Toggle auswählt, THE Theme_Provider SHALL die Auswahl im localStorage des Browsers speichern.
2. WHEN der Benutzer die Webseite erneut besucht und eine gespeicherte Theme_Präferenz vorhanden ist, THE Theme_Provider SHALL die gespeicherte Präferenz anwenden.
3. IF der localStorage nicht verfügbar oder blockiert ist, THEN THE Theme_Provider SHALL auf die System_Theme-Erkennung zurückfallen und ohne Fehler weiterarbeiten.
### Anforderung 4: Definition des Light Themes
**User Story:** Als Besucher möchte ich ein visuell ansprechendes helles Farbschema sehen, das zum bestehenden Design der Webseite passt.
#### Akzeptanzkriterien
1. THE Light_Theme SHALL CSS-Custom-Properties für alle bestehenden Design-Token definieren (background, foreground, card, primary, secondary, muted, accent, border, input, ring, destructive, popover, sidebar, chart-1 bis chart-5).
2. THE Light_Theme SHALL helle Hintergrundfarben und dunkle Textfarben verwenden, die einen WCAG-AA-konformen Kontrast aufweisen.
3. THE Light_Theme SHALL die bestehenden Komponentenstile (glass-nav, card-minimal, text-gradient, btn-minimal, btn-outline, project-card) visuell korrekt darstellen.
4. WHEN das Light_Theme aktiv ist, THE Theme_Provider SHALL die CSS-Klasse vom `<html>`-Element so setzen, dass Tailwind-Utility-Klassen mit `dark:`-Präfix korrekt reagieren.
### Anforderung 5: Vermeidung von Theme-Flackern beim Laden
**User Story:** Als Besucher möchte ich beim Laden der Seite kein kurzes Aufblitzen des falschen Themes sehen, damit das Erlebnis professionell wirkt.
#### Akzeptanzkriterien
1. THE Theme_Provider SHALL ein Inline-Script im `<head>` des HTML-Dokuments verwenden, das die gespeicherte Theme_Präferenz oder das System_Theme ausliest und die entsprechende CSS-Klasse auf das `<html>`-Element setzt, bevor der Seiteninhalt gerendert wird.
2. WHEN die Seite geladen wird, THE Theme_Provider SHALL das korrekte Theme ohne sichtbares Flackern oder Farbwechsel anzeigen.
### Anforderung 6: Barrierefreiheit des Theme-Toggles
**User Story:** Als Besucher mit Einschränkungen möchte ich den Theme-Toggle per Tastatur und Screenreader bedienen können.
#### Akzeptanzkriterien
1. THE Theme_Toggle SHALL per Tastatur fokussierbar und bedienbar sein (Tab-Navigation und Enter/Space zum Aktivieren).
2. THE Theme_Toggle SHALL ein `aria-label`-Attribut besitzen, das den aktuellen Zustand und die Funktion beschreibt (z. B. „Theme wechseln, aktuell: Dunkel").
3. WHEN der Benutzer das Theme über den Theme_Toggle wechselt, THE Theme_Toggle SHALL den neuen Zustand über ein `aria-live`-Attribut oder eine gleichwertige Methode an Screenreader kommunizieren.

View File

@@ -0,0 +1,114 @@
# Implementation Plan: System-Theme-Erkennung (Light/Dark Mode)
## Overview
Integrate `next-themes` into the existing React + Vite + Tailwind + shadcn/ui app to enable automatic OS theme detection, manual theme switching (light/dark/system), and persistent user preference. The CSS variables in `src/index.css` will be restructured so `:root` defines the light theme and `.dark` defines the dark theme. A ThemeToggle component will be added to the Header, and an inline script in `index.html` will prevent FOUC.
## Tasks
- [x] 1. Restructure CSS variables and set up testing dependencies
- [x] 1.1 Restructure CSS custom properties in `src/index.css`
- Move the current `:root` dark color values into the `.dark` block
- Define new light theme values in `:root` (white backgrounds, dark text, matching design spec)
- Ensure all design tokens are covered: background, foreground, card, primary, secondary, muted, accent, border, input, ring, destructive, popover, sidebar, chart-1 through chart-5
- Update hardcoded color values in component styles (`.glass-nav`, `.card-minimal`, `.project-card`, `.btn`, `.text-gradient`, etc.) to use CSS variables where possible
- Add dark-mode-aware variants for component styles that use hardcoded HSL values
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [x] 1.2 Install `fast-check` as a dev dependency
- Run `npm install --save-dev fast-check`
- _Requirements: (testing infrastructure)_
- [x] 2. Create ThemeProvider wrapper and integrate into App
- [x] 2.1 Create `src/components/ThemeProvider.tsx`
- Export a thin wrapper around `ThemeProvider` from `next-themes`
- Configure with `attribute="class"`, `defaultTheme="system"`, `enableSystem={true}`, `storageKey="theme"`
- Accept `children` and optional override props
- _Requirements: 1.1, 1.2, 1.3, 3.1, 3.2_
- [x] 2.2 Wrap the App component tree with ThemeProvider in `src/App.tsx`
- Import ThemeProvider and wrap it as the outermost provider around the existing component tree
- _Requirements: 1.1, 2.3, 2.4, 2.5_
- [ ]* 2.3 Write property test: System-Theme-Auflösung (Property 1)
- **Property 1: System-Theme-Auflösung**
- Generate arbitrary system theme values (light/dark), set preference to "system", verify `resolvedTheme === systemTheme`
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 1.1, 1.2, 1.3, 2.5**
- [ ]* 2.4 Write property test: Explizite Theme-Wahl überschreibt System (Property 2)
- **Property 2: Explizite Theme-Wahl überschreibt System**
- Generate arbitrary combinations of system theme and explicit choice ("light"/"dark"), verify `resolvedTheme === expliziteWahl`
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 2.3, 2.4**
- [ ]* 2.5 Write property test: Persistenz-Round-Trip (Property 3)
- **Property 3: Persistenz-Round-Trip**
- Generate arbitrary valid theme values ("light", "dark", "system"), set via `setTheme()`, read from `localStorage`, verify equality and correct restoration on simulated reload
- Use `fast-check` with minimum 100 iterations
- **Validates: Requirements 3.1, 3.2**
- [x] 3. Checkpoint
- Ensure all tests pass, ask the user if questions arise.
- [x] 4. Create ThemeToggle component
- [x] 4.1 Create `src/components/ThemeToggle.tsx`
- Build a button component using shadcn/ui `DropdownMenu` and `Button`
- Use `useTheme()` hook from `next-themes` for `theme`, `setTheme`, `resolvedTheme`
- Display Sun icon (lucide-react) for light, Moon for dark, Monitor for system
- Implement mounted-state check to prevent hydration mismatch on icon rendering
- Dropdown offers three options: "Hell" (light), "Dunkel" (dark), "System" (system)
- Add `aria-label` describing current state (e.g., "Theme wechseln, aktuell: Dunkel")
- Ensure keyboard navigability (Tab, Enter/Space)
- Accept optional `className` prop for positioning flexibility
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 6.1, 6.2, 6.3_
- [ ]* 4.2 Write unit tests for ThemeToggle
- Test that the toggle button renders in the DOM
- Test that dropdown shows three options (Hell, Dunkel, System)
- Test correct icon rendering per theme state
- Test `aria-label` contains current theme state
- Test keyboard interaction (focus, Enter/Space opens dropdown)
- _Requirements: 2.1, 2.2, 2.6, 6.1, 6.2, 6.3_
- [x] 5. Integrate ThemeToggle into Header
- [x] 5.1 Add ThemeToggle to desktop navigation in `src/components/Header.tsx`
- Import ThemeToggle and place it in the `NavBody` actions area, before the "Kontakt" button
- _Requirements: 2.1_
- [x] 5.2 Add ThemeToggle to mobile navigation in `src/components/Header.tsx`
- Place ThemeToggle in the `MobileNavHeader`, positioned next to the hamburger toggle icon
- _Requirements: 2.1_
- [ ]* 5.3 Write unit tests for Header ThemeToggle integration
- Test that ThemeToggle is present in the rendered Header component
- _Requirements: 2.1_
- [x] 6. Add FOUC-prevention inline script to `index.html`
- [x] 6.1 Add inline `<script>` block in the `<head>` of `index.html`
- Read `localStorage.getItem("theme")`
- If value is "dark", add `class="dark"` to `<html>`
- If value is "system" or absent, check `window.matchMedia("(prefers-color-scheme: dark)")` and set class accordingly
- If value is "light", ensure no `dark` class is present
- Script must execute synchronously before any rendering
- _Requirements: 5.1, 5.2, 3.2_
- [ ]* 6.2 Write unit test for FOUC-prevention logic
- Extract the inline script logic into a testable function
- Test that correct class is applied for each localStorage value and system preference combination
- _Requirements: 5.1_
- [x] 7. Final checkpoint
- Ensure all tests pass, ask the user if questions arise.
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate universal correctness properties from the design document
- Unit tests validate specific examples and edge cases
- The project uses TypeScript throughout — all new files should be `.tsx`/`.ts`
- `next-themes` is already installed (`^0.3.0`), `fast-check` needs to be added as a dev dependency
- `lucide-react` is already available for Sun/Moon/Monitor icons
- shadcn/ui `DropdownMenu` and `Button` components already exist in `src/components/ui/`

View File

@@ -2,6 +2,27 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<script>
(function() {
try {
var theme = localStorage.getItem("theme");
var isDark =
theme === "dark" ||
((theme === "system" || !theme) &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
} catch (e) {
// localStorage blocked or unavailable — fall back to system preference
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
}
}
})();
</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WEBklar</title> <title>WEBklar</title>

89
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": { "": {
"name": "vite_react_shadcn_ts", "name": "vite_react_shadcn_ts",
"version": "0.0.0", "version": "0.0.0",
"hasInstallScript": true,
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.11",
@@ -50,6 +51,7 @@
"motion": "^12.29.2", "motion": "^12.29.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"ogl": "^1.0.11", "ogl": "^1.0.11",
"postprocessing": "^6.36.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -77,6 +79,7 @@
"eslint": "^9.32.0", "eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"fast-check": "^4.7.0",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13", "lovable-tagger": "^1.1.13",
@@ -139,7 +142,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -3054,7 +3056,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -3202,7 +3205,6 @@
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3218,7 +3220,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -3230,7 +3231,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -3296,7 +3296,6 @@
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.38.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.38.0",
@@ -3666,7 +3665,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3972,7 +3970,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -4502,7 +4499,6 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -4617,7 +4613,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dom-helpers": { "node_modules/dom-helpers": {
"version": "5.2.1", "version": "5.2.1",
@@ -4675,8 +4672,7 @@
"version": "8.6.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/embla-carousel-react": { "node_modules/embla-carousel-react": {
"version": "8.6.0", "version": "8.6.0",
@@ -4865,7 +4861,6 @@
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5078,6 +5073,29 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5884,7 +5902,6 @@
"integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"abab": "^2.0.6", "abab": "^2.0.6",
"acorn": "^8.8.1", "acorn": "^8.8.1",
@@ -6611,6 +6628,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -7125,7 +7143,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -7250,6 +7267,15 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/postprocessing": {
"version": "6.39.1",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz",
"integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==",
"license": "Zlib",
"peerDependencies": {
"three": ">= 0.168.0 < 0.185.0"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7266,6 +7292,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -7281,6 +7308,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -7291,6 +7319,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -7303,7 +7332,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
@@ -7345,6 +7375,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/querystringify": { "node_modules/querystringify": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@@ -7377,7 +7424,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -7404,7 +7450,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -7418,7 +7463,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -8172,7 +8216,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -8239,8 +8282,7 @@
"version": "0.182.0", "version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
@@ -8303,7 +8345,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -8436,7 +8477,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -8637,7 +8677,6 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

View File

@@ -58,6 +58,7 @@
"motion": "^12.29.2", "motion": "^12.29.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"ogl": "^1.0.11", "ogl": "^1.0.11",
"postprocessing": "^6.36.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -85,6 +86,7 @@
"eslint": "^9.32.0", "eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"fast-check": "^4.7.0",
"globals": "^15.15.0", "globals": "^15.15.0",
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13", "lovable-tagger": "^1.1.13",

14
public/svg/csharp.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<path fill="#9B4F96" d="M115.4 30.7L67.1 2.9c-.8-.5-1.9-.7-3.1-.7-1.2 0-2.3.3-3.1.7l-48 27.9c-1.7 1-2.9 3.5-2.9 5.4v55.7c0 1.1.2 2.4 1 3.5l106.8-62c-.6-1.2-1.5-2.1-2.4-2.7z"/>
<path fill="#68217A" d="M10.7 95.3c.5.8 1.2 1.5 1.9 1.9l48.2 27.9c.8.5 1.9.7 3.1.7 1.2 0 2.3-.3 3.1-.7l48-27.9c1.7-1 2.9-3.5 2.9-5.4V36.1c0-.9-.1-1.9-.6-2.8l-106.6 62z"/>
<path fill="#fff" d="M85.3 76.1C81.1 83.5 73.1 88.5 64 88.5c-13.5 0-24.5-11-24.5-24.5s11-24.5 24.5-24.5c9.1 0 17.1 5 21.3 12.5l13-7.5c-6.8-11.9-19.6-20-34.3-20-21.8 0-39.5 17.7-39.5 39.5s17.7 39.5 39.5 39.5c14.6 0 27.4-8 34.2-19.8l-12.9-7.6z"/>
<!-- # symbol: two slanted verticals + two horizontals -->
<g stroke="#fff" stroke-width="3.5" stroke-linecap="round" fill="none">
<!-- two vertical lines (slightly tilted) -->
<line x1="95" y1="48" x2="92" y2="80"/>
<line x1="105" y1="48" x2="102" y2="80"/>
<!-- two horizontal lines -->
<line x1="87" y1="57" x2="112" y2="57"/>
<line x1="87" y1="69" x2="112" y2="69"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/svg/html5.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="30" height="30"><path fill="#E44D26" d="M19.037 113.876L9.032 1.661h109.936l-10.016 112.198-45.019 12.48z"/><path fill="#F16529" d="M64 116.8l36.378-10.086 8.559-95.878H64z"/><path fill="#EBEBEB" d="M64 52.455H45.788L44.53 38.361H64V24.599H29.489l.33 3.692 3.382 37.927H64zm0 35.743l-.061.017-15.327-4.14-.979-10.975H33.816l1.928 21.609 28.193 7.826.063-.017z"/><path fill="#fff" d="M63.952 52.455v13.763h16.947l-1.597 17.849-15.35 4.143v14.319l28.215-7.82.207-2.325 3.234-36.233.335-3.696h-3.708zm0-27.856v13.762h33.244l.276-3.092.628-6.978.329-3.692z"/></svg>

After

Width:  |  Height:  |  Size: 630 B

1
public/svg/nextjs.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="30" height="30"><path fill="#fff" d="M64 0C28.7 0 0 28.7 0 64s28.7 64 64 64c11.2 0 21.7-2.9 30.8-7.9L48.4 55.3v36.6h-6.8V41.8h6.8l50.5 75.8C116.4 106.3 128 86.5 128 64c0-35.3-28.7-64-64-64zm22.1 84.6l-7.5-11.7V41.8h7.5v42.8z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

1
public/svg/php.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="30" height="30"><path fill="#6181B6" d="M64 33.039C30.26 33.039 2.906 46.901 2.906 64S30.26 94.961 64 94.961 125.094 81.099 125.094 64 97.74 33.039 64 33.039zM48.103 70.032c-1.458 1.364-3.077 1.927-4.86 2.507-1.783.581-4.052.461-6.811.461h-6.253l-1.733 10h-7.301l6.515-34H41.7c4.224 0 7.305 1.215 9.242 3.432 1.937 2.217 2.519 5.364 1.747 9.337-.319 1.637-.856 3.159-1.614 4.515a15.118 15.118 0 01-2.972 3.748zM69.414 73l2.881-14.42c.328-1.688.132-2.913-.59-3.676-.723-.764-2.017-1.146-3.882-1.146h-6.324L57.858 73H50.6l6.515-34h7.258l-1.694 8.455h6.676c4.523 0 7.577 1.019 9.161 3.057 1.584 2.037 1.937 5.098 1.059 9.183L77.012 73h-7.598zM103.821 66.28c-.319 1.637-.856 3.133-1.614 4.489a15.015 15.015 0 01-2.972 3.722c-1.458 1.364-3.096 1.953-4.86 2.507-1.764.555-4.052.461-6.812.461H81.31l-1.733 10h-7.301l6.514-34h14.041c4.224 0 7.305 1.215 9.241 3.432 1.935 2.217 2.519 5.39 1.749 9.389z"/><path fill="#fff" d="M38.94 49.758h-5.709l-3.233 15.242h5.417c2.894 0 5.146-.66 6.757-1.98 1.61-1.32 2.793-3.479 3.548-6.475.716-2.845.418-4.854-.893-6.024S41.752 49.758 38.94 49.758zM95.088 49.758h-5.709l-3.233 15.242h5.417c2.894 0 5.146-.66 6.757-1.98 1.61-1.32 2.793-3.479 3.548-6.475.716-2.845.418-4.854-.893-6.024-1.312-1.171-3.56-1.763-6.887-.763z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/svg/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.232 23 20.463"><circle r="2.05" fill="#61dafb"/><g stroke="#61dafb" fill="none" stroke-width="1"><ellipse rx="11" ry="4.2"/><ellipse rx="11" ry="4.2" transform="rotate(60)"/><ellipse rx="11" ry="4.2" transform="rotate(120)"/></g></svg>

After

Width:  |  Height:  |  Size: 294 B

1
public/svg/sql.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="30" height="30"><path fill="#00758F" d="M64 8C37.5 8 16 15.2 16 24v80c0 8.8 21.5 16 48 16s48-7.2 48-16V24c0-8.8-21.5-16-48-16z"/><ellipse cx="64" cy="24" rx="48" ry="16" fill="#00758F"/><ellipse cx="64" cy="24" rx="48" ry="16" fill="#00A4CC" opacity=".6"/><path fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" d="M16 24v80c0 8.8 21.5 16 48 16s48-7.2 48-16V24"/><ellipse cx="64" cy="24" rx="48" ry="16" fill="none" stroke="#fff" stroke-width="2"/><path fill="none" stroke="#fff" stroke-width="2" d="M16 44c0 8.8 21.5 16 48 16s48-7.2 48-16M16 64c0 8.8 21.5 16 48 16s48-7.2 48-16M16 84c0 8.8 21.5 16 48 16s48-7.2 48-16"/><text x="64" y="28" text-anchor="middle" fill="#fff" font-family="Arial,sans-serif" font-size="14" font-weight="bold">SQL</text></svg>

After

Width:  |  Height:  |  Size: 839 B

1
public/svg/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 410 404"><path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.505 198.07 388.84L10.5765 59.8364C6.18066 52.5765 12.8498 43.8389 21.1592 45.6998L204.89 87.6784C206.364 88.0124 207.893 88.0124 209.367 87.6784L389.098 45.6838C397.356 43.7568 404.1 52.3792 399.641 59.5246Z" fill="#41D1FF"/><path d="M292.965 1.47363L156.801 28.2552C153.526 28.8698 151.149 31.7254 151.194 35.0584L155.278 209.419C155.345 213.293 159.063 216.088 162.81 215.107L195.608 206.208C199.795 205.112 203.792 208.378 203.472 212.7L200.263 255.828C199.926 260.355 204.35 263.674 208.645 261.96L231.375 253.174C235.676 251.458 240.104 254.784 239.762 259.316L234.968 322.063C234.44 328.958 243.803 332.017 247.238 325.993L249.244 322.464L346.645 93.4963C348.636 88.9498 344.955 84.0155 340.088 84.8845L306.07 90.8262C301.638 91.6108 297.87 87.7874 298.778 83.3874L313.389 12.1816C314.303 7.75199 310.479 3.92727 306.053 4.77862L292.965 1.47363Z" fill="#FFDD35"/></svg>

After

Width:  |  Height:  |  Size: 1007 B

View File

@@ -3,15 +3,18 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ThemeProvider } from "@/components/ThemeProvider";
import Index from "./pages/Index"; import Index from "./pages/Index";
import ContactPage from "./pages/Contact"; import ContactPage from "./pages/Contact";
import AGBPage from "./pages/AGB"; import AGBPage from "./pages/AGB";
import ImpressumPage from "./pages/Impressum"; import ImpressumPage from "./pages/Impressum";
import AboutPage from "./pages/About";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = () => ( const App = () => (
<ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
@@ -22,12 +25,14 @@ const App = () => (
<Route path="/kontakt" element={<ContactPage />} /> <Route path="/kontakt" element={<ContactPage />} />
<Route path="/agb" element={<AGBPage />} /> <Route path="/agb" element={<AGBPage />} />
<Route path="/impressum" element={<ImpressumPage />} /> <Route path="/impressum" element={<ImpressumPage />} />
<Route path="/ueber-uns" element={<AboutPage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
); );
export default App; export default App;

View File

@@ -0,0 +1,156 @@
.border-glow-card {
--edge-proximity: 0;
--cursor-angle: 45deg;
--edge-sensitivity: 30;
--color-sensitivity: calc(var(--edge-sensitivity) + 20);
--border-radius: 28px;
--glow-padding: 40px;
--cone-spread: 25;
position: relative;
border-radius: var(--border-radius);
isolation: isolate;
transform: translate3d(0, 0, 0.01px);
display: grid;
border: 1px solid rgb(255 255 255 / 8%);
background: var(--card-bg, #000);
overflow: hidden;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
box-shadow:
rgba(0, 0, 0, 0.1) 0px 1px 2px,
rgba(0, 0, 0, 0.1) 0px 2px 4px,
rgba(0, 0, 0, 0.1) 0px 4px 8px,
rgba(0, 0, 0, 0.1) 0px 8px 16px,
rgba(0, 0, 0, 0.1) 0px 16px 32px,
rgba(0, 0, 0, 0.1) 0px 32px 64px;
}
.border-glow-card::before,
.border-glow-card::after,
.border-glow-card > .edge-light {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
transition: opacity 0.25s ease-out;
z-index: -1;
pointer-events: none;
}
.border-glow-card:not(:hover):not(.sweep-active)::before,
.border-glow-card:not(:hover):not(.sweep-active)::after,
.border-glow-card:not(:hover):not(.sweep-active) > .edge-light {
opacity: 0 !important;
visibility: hidden;
transition: opacity 0.75s ease-in-out, visibility 0.75s ease-in-out;
}
/* colored mesh-gradient border */
.border-glow-card::before {
border: 1px solid transparent;
background:
linear-gradient(var(--card-bg, #120F17) 0 100%) padding-box,
linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box,
var(--gradient-one, radial-gradient(at 80% 55%, hsla(268, 100%, 76%, 1) 0px, transparent 50%)) border-box,
var(--gradient-two, radial-gradient(at 69% 34%, hsla(349, 100%, 74%, 1) 0px, transparent 50%)) border-box,
var(--gradient-three, radial-gradient(at 8% 6%, hsla(136, 100%, 78%, 1) 0px, transparent 50%)) border-box,
var(--gradient-four, radial-gradient(at 41% 38%, hsla(192, 100%, 64%, 1) 0px, transparent 50%)) border-box,
var(--gradient-five, radial-gradient(at 86% 85%, hsla(186, 100%, 74%, 1) 0px, transparent 50%)) border-box,
var(--gradient-six, radial-gradient(at 82% 18%, hsla(52, 100%, 65%, 1) 0px, transparent 50%)) border-box,
var(--gradient-seven, radial-gradient(at 51% 4%, hsla(12, 100%, 72%, 1) 0px, transparent 50%)) border-box,
var(--gradient-base, linear-gradient(#c299ff 0 100%)) border-box;
opacity: calc(
(var(--edge-proximity) - var(--color-sensitivity)) /
(100 - var(--color-sensitivity))
);
mask-image: conic-gradient(
from var(--cursor-angle) at center,
black calc(var(--cone-spread) * 1%),
transparent calc((var(--cone-spread) + 15) * 1%),
transparent calc((100 - var(--cone-spread) - 15) * 1%),
black calc((100 - var(--cone-spread)) * 1%)
);
}
/* colored mesh-gradient background fill near edges */
.border-glow-card::after {
border: 1px solid transparent;
background:
var(--gradient-one, radial-gradient(at 80% 55%, hsla(268, 100%, 76%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-two, radial-gradient(at 69% 34%, hsla(349, 100%, 74%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-three, radial-gradient(at 8% 6%, hsla(136, 100%, 78%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-four, radial-gradient(at 41% 38%, hsla(192, 100%, 64%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-five, radial-gradient(at 86% 85%, hsla(186, 100%, 74%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-six, radial-gradient(at 82% 18%, hsla(52, 100%, 65%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-seven, radial-gradient(at 51% 4%, hsla(12, 100%, 72%, 1) 0px, transparent 50%)) padding-box,
var(--gradient-base, linear-gradient(#c299ff 0 100%)) padding-box;
mask-image:
linear-gradient(to bottom, black, black),
radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%),
radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%),
radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%),
radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%),
radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%),
conic-gradient(
from var(--cursor-angle) at center,
transparent 5%,
black 15%,
black 85%,
transparent 95%
);
mask-composite: subtract, add, add, add, add, add;
opacity: calc(
var(--fill-opacity, 0.5) *
(var(--edge-proximity) - var(--color-sensitivity)) /
(100 - var(--color-sensitivity))
);
mix-blend-mode: soft-light;
}
/* outer glow layer */
.border-glow-card > .edge-light {
inset: 0;
pointer-events: none;
z-index: 1;
mask-image: conic-gradient(
from var(--cursor-angle) at center,
black 2.5%,
transparent 10%,
transparent 90%,
black 97.5%
);
opacity: calc(
(var(--edge-proximity) - var(--edge-sensitivity)) /
(100 - var(--edge-sensitivity))
);
mix-blend-mode: plus-lighter;
}
.border-glow-card > .edge-light::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow:
inset 0 0 0 1px var(--glow-color, hsl(40deg 80% 80% / 100%)),
inset 0 0 1px 0 var(--glow-color-60, hsl(40deg 80% 80% / 60%)),
inset 0 0 3px 0 var(--glow-color-50, hsl(40deg 80% 80% / 50%)),
inset 0 0 6px 0 var(--glow-color-40, hsl(40deg 80% 80% / 40%)),
inset 0 0 15px 0 var(--glow-color-30, hsl(40deg 80% 80% / 30%)),
inset 0 0 25px 2px var(--glow-color-20, hsl(40deg 80% 80% / 20%)),
inset 0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%)),
0 0 1px 0 var(--glow-color-60, hsl(40deg 80% 80% / 60%)),
0 0 3px 0 var(--glow-color-50, hsl(40deg 80% 80% / 50%)),
0 0 6px 0 var(--glow-color-40, hsl(40deg 80% 80% / 40%)),
0 0 15px 0 var(--glow-color-30, hsl(40deg 80% 80% / 30%)),
0 0 25px 2px var(--glow-color-20, hsl(40deg 80% 80% / 20%)),
0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%));
}
.border-glow-inner {
display: flex;
flex-direction: column;
position: relative;
overflow: auto;
z-index: 1;
}

View File

@@ -0,0 +1,184 @@
import { useRef, useCallback, useEffect, type ReactNode } from 'react';
import './BorderGlow.css';
function parseHSL(hslStr: string) {
const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/);
if (!match) return { h: 40, s: 80, l: 80 };
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
}
function buildGlowVars(glowColor: string, intensity: number) {
const { h, s, l } = parseHSL(glowColor);
const base = `${h}deg ${s}% ${l}%`;
const opacities = [100, 60, 50, 40, 30, 20, 10];
const keys = ['', '-60', '-50', '-40', '-30', '-20', '-10'];
const vars: Record<string, string> = {};
for (let i = 0; i < opacities.length; i++) {
vars[`--glow-color${keys[i]}`] = `hsl(${base} / ${Math.min(opacities[i] * intensity, 100)}%)`;
}
return vars;
}
const GRADIENT_POSITIONS = ['80% 55%', '69% 34%', '8% 6%', '41% 38%', '86% 85%', '82% 18%', '51% 4%'];
const GRADIENT_KEYS = ['--gradient-one', '--gradient-two', '--gradient-three', '--gradient-four', '--gradient-five', '--gradient-six', '--gradient-seven'];
const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1];
function buildGradientVars(colors: string[]) {
const vars: Record<string, string> = {};
for (let i = 0; i < 7; i++) {
const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)];
vars[GRADIENT_KEYS[i]] = `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`;
}
vars['--gradient-base'] = `linear-gradient(${colors[0]} 0 100%)`;
return vars;
}
function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); }
function easeInCubic(x: number) { return x * x * x; }
function animateValue({ start = 0, end = 100, duration = 1000, delay = 0, ease = easeOutCubic, onUpdate, onEnd }: {
start?: number; end?: number; duration?: number; delay?: number;
ease?: (x: number) => number; onUpdate: (v: number) => void; onEnd?: () => void;
}) {
const t0 = performance.now() + delay;
function tick() {
const elapsed = performance.now() - t0;
const t = Math.min(elapsed / duration, 1);
onUpdate(start + (end - start) * ease(t));
if (t < 1) requestAnimationFrame(tick);
else if (onEnd) onEnd();
}
setTimeout(() => requestAnimationFrame(tick), delay);
}
interface BorderGlowProps {
children: ReactNode;
className?: string;
edgeSensitivity?: number;
glowColor?: string;
backgroundColor?: string;
borderRadius?: number;
glowRadius?: number;
glowIntensity?: number;
coneSpread?: number;
animated?: boolean;
colors?: string[];
fillOpacity?: number;
}
const BorderGlow = ({
children,
className = '',
edgeSensitivity = 30,
glowColor = '40 80 80',
backgroundColor = '#120F17',
borderRadius = 28,
glowRadius = 40,
glowIntensity = 1.0,
coneSpread = 25,
animated = false,
colors = ['#c084fc', '#f472b6', '#38bdf8'],
fillOpacity = 0.5,
}: BorderGlowProps) => {
const cardRef = useRef<HTMLDivElement>(null);
const getCenterOfElement = useCallback((el: HTMLElement) => {
const { width, height } = el.getBoundingClientRect();
return [width / 2, height / 2];
}, []);
const getEdgeProximity = useCallback((el: HTMLElement, x: number, y: number) => {
const [cx, cy] = getCenterOfElement(el);
const dx = x - cx;
const dy = y - cy;
let kx = Infinity;
let ky = Infinity;
if (dx !== 0) kx = cx / Math.abs(dx);
if (dy !== 0) ky = cy / Math.abs(dy);
return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1);
}, [getCenterOfElement]);
const getCursorAngle = useCallback((el: HTMLElement, x: number, y: number) => {
const [cx, cy] = getCenterOfElement(el);
const dx = x - cx;
const dy = y - cy;
if (dx === 0 && dy === 0) return 0;
const radians = Math.atan2(dy, dx);
let degrees = radians * (180 / Math.PI) + 90;
if (degrees < 0) degrees += 360;
return degrees;
}, [getCenterOfElement]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
const card = cardRef.current;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const edge = getEdgeProximity(card, x, y);
const angle = getCursorAngle(card, x, y);
card.style.setProperty('--edge-proximity', `${(edge * 100).toFixed(3)}`);
card.style.setProperty('--cursor-angle', `${angle.toFixed(3)}deg`);
}, [getEdgeProximity, getCursorAngle]);
useEffect(() => {
if (!animated || !cardRef.current) return;
const card = cardRef.current;
const angleStart = 110;
const angleEnd = 465;
let cancelled = false;
function runSweep() {
if (cancelled || !card) return;
card.classList.add('sweep-active');
card.style.setProperty('--cursor-angle', `${angleStart}deg`);
animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) });
animateValue({
ease: easeInCubic, duration: 1500, end: 50,
onUpdate: v => { card.style.setProperty('--cursor-angle', `${(angleEnd - angleStart) * (v / 100) + angleStart}deg`); }
});
animateValue({
ease: easeOutCubic, delay: 1500, duration: 2250, start: 50, end: 100,
onUpdate: v => { card.style.setProperty('--cursor-angle', `${(angleEnd - angleStart) * (v / 100) + angleStart}deg`); }
});
animateValue({
ease: easeInCubic, delay: 2500, duration: 1500, start: 100, end: 0,
onUpdate: v => card.style.setProperty('--edge-proximity', String(v)),
onEnd: () => {
card.classList.remove('sweep-active');
if (!cancelled) setTimeout(runSweep, 800);
},
});
}
runSweep();
return () => { cancelled = true; };
}, [animated]);
const glowVars = buildGlowVars(glowColor, glowIntensity);
return (
<div
ref={cardRef}
onPointerMove={animated ? undefined : handlePointerMove}
className={`border-glow-card ${className}`}
style={{
'--card-bg': backgroundColor,
'--edge-sensitivity': edgeSensitivity,
'--border-radius': `${borderRadius}px`,
'--glow-padding': `${glowRadius}px`,
'--cone-spread': coneSpread,
'--fill-opacity': fillOpacity,
...glowVars,
...buildGradientVars(colors),
} as React.CSSProperties}
>
<span className="edge-light" />
<div className="border-glow-inner">
{children}
</div>
</div>
);
};
export default BorderGlow;

View File

@@ -1,13 +1,25 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowRight, Mail, Phone } from "lucide-react"; import { ArrowRight, Mail, Phone } from "lucide-react";
import BorderGlow from "@/components/BorderGlow";
const Contact = () => { const Contact = () => {
return ( return (
<section id="contact" className="py-24 md:py-32 bg-background relative"> <section id="contact" className="py-24 md:py-32 bg-background relative">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
<div className="max-w-4xl"> <div className="max-w-6xl mx-auto">
{/* Section Header */} <BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="#120F17"
borderRadius={28}
glowRadius={40}
glowIntensity={1}
coneSpread={25}
animated
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-8 md:p-12 lg:p-16">
<div className="mb-12"> <div className="mb-12">
<div className="label-tag mb-4">Kontakt</div> <div className="label-tag mb-4">Kontakt</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-6"> <h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-6">
@@ -20,7 +32,6 @@ const Contact = () => {
</p> </p>
</div> </div>
{/* CTA */}
<div className="flex flex-col sm:flex-row gap-4 mb-16"> <div className="flex flex-col sm:flex-row gap-4 mb-16">
<Link to="/kontakt"> <Link to="/kontakt">
<Button <Button
@@ -33,7 +44,6 @@ const Contact = () => {
</Link> </Link>
</div> </div>
{/* Contact Info */}
<div className="divider mb-12" /> <div className="divider mb-12" />
<div className="flex flex-col sm:flex-row gap-8 text-muted-foreground"> <div className="flex flex-col sm:flex-row gap-8 text-muted-foreground">
<a href="mailto:support@webklar.com" className="flex items-center gap-3 hover:text-foreground transition-colors group"> <a href="mailto:support@webklar.com" className="flex items-center gap-3 hover:text-foreground transition-colors group">
@@ -46,6 +56,8 @@ const Contact = () => {
</a> </a>
</div> </div>
</div> </div>
</BorderGlow>
</div>
</div> </div>
</section> </section>
); );

View File

@@ -14,10 +14,11 @@ import {
MobileNavMenu, MobileNavMenu,
} from "@/components/ui/resizable-navbar"; } from "@/components/ui/resizable-navbar";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import { ThemeToggle } from "@/components/ThemeToggle";
const Header = () => { const Header = () => {
const navItems = [ const navItems = [
{ name: "Über uns", link: "#about" }, { name: "Über uns", link: "/ueber-uns" },
{ name: "Leistungen", link: "#services" }, { name: "Leistungen", link: "#services" },
{ name: "Projekte", link: "#projects" }, { name: "Projekte", link: "#projects" },
{ name: "Ablauf", link: "#process" }, { name: "Ablauf", link: "#process" },
@@ -38,6 +39,7 @@ const Header = () => {
</NavbarLogo> </NavbarLogo>
<NavItems items={navItems} /> <NavItems items={navItems} />
<div className="navbar-actions flex items-center gap-4"> <div className="navbar-actions flex items-center gap-4">
<ThemeToggle />
<Link to="/kontakt"> <Link to="/kontakt">
<NavbarButton <NavbarButton
as="span" as="span"
@@ -58,17 +60,32 @@ const Header = () => {
Webklar Webklar
</span> </span>
</NavbarLogo> </NavbarLogo>
<div className="flex items-center gap-2">
<ThemeToggle />
<MobileNavToggle <MobileNavToggle
isOpen={isMobileMenuOpen} isOpen={isMobileMenuOpen}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
/> />
</div>
</MobileNavHeader> </MobileNavHeader>
<MobileNavMenu <MobileNavMenu
isOpen={isMobileMenuOpen} isOpen={isMobileMenuOpen}
onClose={() => setIsMobileMenuOpen(false)} onClose={() => setIsMobileMenuOpen(false)}
> >
{navItems.map((item, idx) => ( {navItems.map((item, idx) =>
item.link.startsWith("/") ? (
<Link
key={`mobile-link-${idx}`}
to={item.link}
onClick={() => setIsMobileMenuOpen(false)}
className="relative text-neutral-600 dark:text-neutral-300"
>
<span className="block font-medium uppercase tracking-wider">
{item.name}
</span>
</Link>
) : (
<a <a
key={`mobile-link-${idx}`} key={`mobile-link-${idx}`}
href={item.link} href={item.link}
@@ -79,7 +96,8 @@ const Header = () => {
{item.name} {item.name}
</span> </span>
</a> </a>
))} )
)}
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
<Link to="/kontakt" onClick={() => setIsMobileMenuOpen(false)}> <Link to="/kontakt" onClick={() => setIsMobileMenuOpen(false)}>
<NavbarButton <NavbarButton

View File

@@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import Silk from "@/components/Silk"; import Silk from "@/components/Silk";
import CountUp from "@/components/CountUp"; import CountUp from "@/components/CountUp";
@@ -48,6 +49,7 @@ const FOUNDING_DATE = new Date("2026-01-25"); // Samstag, 25. Januar 2026
const Hero = () => { const Hero = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { resolvedTheme } = useTheme();
const [companyAge, setCompanyAge] = useState(""); const [companyAge, setCompanyAge] = useState("");
const secondBtnRef = useRef<HTMLButtonElement>(null); const secondBtnRef = useRef<HTMLButtonElement>(null);
@@ -96,9 +98,19 @@ const Hero = () => {
calculateAge(); calculateAge();
const interval = setInterval(calculateAge, 60 * 60 * 1000); // Update every hour (only days/hours shown) const interval = setInterval(calculateAge, 60 * 60 * 1000); // Update every hour (only days/hours shown)
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// ── Silk-Hintergrund Farben ──
const isDark = resolvedTheme === "dark";
// Silk: Hauptfarbe (Wellenspitzen)
const silkColor = isDark ? "#6a6a6a" : "#ffffff";
// Silk: Zweite Farbe (Wellentäler)
const silkColor2 = isDark ? "#000000" : "#c0c0c0";
// Silk: Rausch-Intensität
const silkNoise = isDark ? 4 : 1.5;
return ( return (
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20"> <section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20">
{/* Silk animated background */} {/* Silk animated background */}
@@ -106,9 +118,9 @@ const Hero = () => {
<Silk <Silk
speed={3} speed={3}
scale={0.5} scale={0.5}
color="#6a6a6a" color={silkColor}
noiseIntensity={4 color2={silkColor2}
} noiseIntensity={silkNoise}
rotation={0} rotation={0}
/> />
</div> </div>

View File

@@ -1,33 +1,76 @@
const tools = ["Cursor", "Kiro", "N8N", "Mistral", "Hetzner", "Porkbun", "Appwrite", "Traefik"]; const technologies = [
{ name: "C#", logo: "/svg/csharp.svg" },
{ name: "PHP", logo: "/svg/php.svg" },
{ name: "HTML5", logo: "/svg/html5.svg" },
{ name: "React", logo: "/svg/react.svg" },
{ name: "Next.js", logo: "/svg/nextjs.svg" },
{ name: "Vite", logo: "/svg/vite.svg" },
{ name: "SQL", logo: "/svg/sql.svg" },
];
const Partners = () => { const Partners = () => {
return <section className="py-16 md:py-20 bg-background border-y border-border overflow-hidden"> return (
<div className="container mx-auto px-6 mb-8"> <section className="py-8 md:py-10 bg-background border-y border-border overflow-hidden">
<div className="label-tag text-center">UNSERE TOOLS MIT DEN WIR ARBEITEN</div> <div className="container mx-auto px-6 mb-4">
<div className="label-tag text-center">
PROGRAMMIERSPRACHEN
</div>
</div> </div>
<div className="relative"> <div className="relative overflow-x-hidden py-2">
{/* Fade edges */} {/* Fade edges */}
<div className="absolute left-0 top-0 bottom-0 w-32 bg-gradient-to-r from-background to-transparent z-10" /> <div className="pointer-events-none absolute inset-y-0 left-0 w-32 bg-gradient-to-r from-background to-transparent z-20" />
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-background to-transparent z-10" /> <div className="pointer-events-none absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-background to-transparent z-20" />
{/* Marquee */} {/* Marquee */}
<div className="flex overflow-hidden"> <div className="flex overflow-hidden">
<div className="marquee"> <div className="marquee">
{[...tools, ...tools].map((tool, index) => <div key={`${tool}-${index}`} className="flex items-center justify-center min-w-[160px] px-8"> {[...technologies, ...technologies].map((tech, index) => (
<span className="text-xl md:text-2xl font-display font-medium text-muted-foreground/50 hover:text-foreground transition-colors duration-300 uppercase tracking-wider"> <div
{tool} key={`${tech.name}-${index}`}
className="flex items-center gap-3 pr-12 md:pr-20 group transition-all duration-300"
aria-hidden={index >= technologies.length}
>
<img
src={tech.logo}
alt={tech.name}
className="h-10 w-auto object-contain transition-transform group-hover:scale-110 opacity-100 dark:opacity-60"
width={40}
height={40}
loading={index < technologies.length ? "eager" : "lazy"}
decoding="async"
/>
<span className="text-xl md:text-2xl font-display font-medium text-foreground group-hover:text-muted-foreground dark:text-muted-foreground/50 dark:group-hover:text-foreground transition-colors duration-300 whitespace-nowrap">
{tech.name}
</span> </span>
</div>)} </div>
))}
</div> </div>
<div className="marquee" aria-hidden="true"> <div className="marquee" aria-hidden="true">
{[...tools, ...tools].map((tool, index) => <div key={`${tool}-dup-${index}`} className="flex items-center justify-center min-w-[160px] px-8"> {[...technologies, ...technologies].map((tech, index) => (
<span className="text-xl md:text-2xl font-display font-medium text-muted-foreground/50 hover:text-foreground transition-colors duration-300 uppercase tracking-wider"> <div
{tool} key={`${tech.name}-dup-${index}`}
className="flex items-center gap-3 pr-12 md:pr-20 group transition-all duration-300"
>
<img
src={tech.logo}
alt={tech.name}
className="h-10 w-auto object-contain transition-transform group-hover:scale-110 opacity-100 dark:opacity-60"
width={40}
height={40}
loading="lazy"
decoding="async"
/>
<span className="text-xl md:text-2xl font-display font-medium text-foreground group-hover:text-muted-foreground dark:text-muted-foreground/50 dark:group-hover:text-foreground transition-colors duration-300 whitespace-nowrap">
{tech.name}
</span> </span>
</div>)} </div>
))}
</div> </div>
</div> </div>
</div> </div>
</section>; </section>
);
}; };
export default Partners; export default Partners;

View File

@@ -0,0 +1,6 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

View File

@@ -0,0 +1,525 @@
/* eslint-disable react/no-unknown-property */
import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import './PixelBlast.css';
const createTouchTexture = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D context not available');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const trail: Array<{ x: number; y: number; age: number; force: number; vx: number; vy: number }> = [];
let last: { x: number; y: number } | null = null;
const maxAge = 64;
let radius = 0.1 * size;
const speed = 1 / maxAge;
const clear = () => {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const drawPoint = (p: { x: number; y: number; age: number; force: number; vx: number; vy: number }) => {
const pos = { x: p.x * size, y: (1 - p.y) * size };
let intensity = 1;
const easeOutSine = (t: number) => Math.sin((t * Math.PI) / 2);
const easeOutQuad = (t: number) => -t * (t - 2);
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
intensity *= p.force;
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
const offset = size * 5;
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius;
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
ctx.beginPath();
ctx.fillStyle = 'rgba(255,0,0,1)';
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
ctx.fill();
};
const addTouch = (norm: { x: number; y: number }) => {
let force = 0;
let vx = 0;
let vy = 0;
if (last) {
const dx = norm.x - last.x;
const dy = norm.y - last.y;
if (dx === 0 && dy === 0) return;
const dd = dx * dx + dy * dy;
const d = Math.sqrt(dd);
vx = dx / (d || 1);
vy = dy / (d || 1);
force = Math.min(dd * 10000, 1);
}
last = { x: norm.x, y: norm.y };
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
};
const update = () => {
clear();
for (let i = trail.length - 1; i >= 0; i--) {
const point = trail[i];
const f = point.force * speed * (1 - point.age / maxAge);
point.x += point.vx * f;
point.y += point.vy * f;
point.age++;
if (point.age > maxAge) trail.splice(i, 1);
}
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
texture.needsUpdate = true;
};
return {
canvas,
texture,
addTouch,
update,
set radiusScale(v: number) { radius = 0.1 * size * v; },
get radiusScale() { return radius / (0.1 * size); },
size,
};
};
const createLiquidEffect = (texture: THREE.Texture, opts?: { strength?: number; freq?: number }) => {
const fragment = `
uniform sampler2D uTexture;
uniform float uStrength;
uniform float uTime;
uniform float uFreq;
void mainUv(inout vec2 uv) {
vec4 tex = texture2D(uTexture, uv);
float vx = tex.r * 2.0 - 1.0;
float vy = tex.g * 2.0 - 1.0;
float intensity = tex.b;
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
float amt = uStrength * intensity * wave;
uv += vec2(vx, vy) * amt;
}
`;
return new Effect('LiquidEffect', fragment, {
uniforms: new Map([
['uTexture', new THREE.Uniform(texture)],
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
['uTime', new THREE.Uniform(0)],
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)],
]),
});
};
const SHAPE_MAP: Record<string, number> = { square: 0, circle: 1, triangle: 2, diamond: 3 };
const VERTEX_SRC = `void main() { gl_Position = vec4(position, 1.0); }`;
const FRAGMENT_SRC = `precision highp float;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform float uScale;
uniform float uDensity;
uniform float uPixelJitter;
uniform int uEnableRipples;
uniform float uRippleSpeed;
uniform float uRippleThickness;
uniform float uRippleIntensity;
uniform float uEdgeFade;
uniform int uShapeType;
const int SHAPE_SQUARE = 0;
const int SHAPE_CIRCLE = 1;
const int SHAPE_TRIANGLE = 2;
const int SHAPE_DIAMOND = 3;
const int MAX_CLICKS = 10;
uniform vec2 uClickPos [MAX_CLICKS];
uniform float uClickTimes[MAX_CLICKS];
out vec4 fragColor;
float Bayer2(vec2 a) { a = floor(a); return fract(a.x / 2. + a.y * a.y * .75); }
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
#define FBM_OCTAVES 5
#define FBM_LACUNARITY 1.25
#define FBM_GAIN 1.0
float hash11(float n){ return fract(sin(n)*43758.5453); }
float vnoise(vec3 p){
vec3 ip = floor(p); vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0,0,0), vec3(1,57,113)));
float n100 = hash11(dot(ip + vec3(1,0,0), vec3(1,57,113)));
float n010 = hash11(dot(ip + vec3(0,1,0), vec3(1,57,113)));
float n110 = hash11(dot(ip + vec3(1,1,0), vec3(1,57,113)));
float n001 = hash11(dot(ip + vec3(0,0,1), vec3(1,57,113)));
float n101 = hash11(dot(ip + vec3(1,0,1), vec3(1,57,113)));
float n011 = hash11(dot(ip + vec3(0,1,1), vec3(1,57,113)));
float n111 = hash11(dot(ip + vec3(1,1,1), vec3(1,57,113)));
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
float x00 = mix(n000, n100, w.x); float x10 = mix(n010, n110, w.x);
float x01 = mix(n001, n101, w.x); float x11 = mix(n011, n111, w.x);
float y0 = mix(x00, x10, w.y); float y1 = mix(x01, x11, w.y);
return mix(y0, y1, w.z) * 2.0 - 1.0;
}
float fbm2(vec2 uv, float t){
vec3 p = vec3(uv * uScale, t);
float amp = 1.0; float freq = 1.0; float sum = 1.0;
for (int i = 0; i < FBM_OCTAVES; ++i){ sum += amp * vnoise(p * freq); freq *= FBM_LACUNARITY; amp *= FBM_GAIN; }
return sum * 0.5 + 0.5;
}
float maskCircle(vec2 p, float cov){ float r = sqrt(cov) * .25; float d = length(p - 0.5) - r; float aa = 0.5 * fwidth(d); return cov * (1.0 - smoothstep(-aa, aa, d * 2.0)); }
float maskTriangle(vec2 p, vec2 id, float cov){ bool flip = mod(id.x + id.y, 2.0) > 0.5; if (flip) p.x = 1.0 - p.x; float r = sqrt(cov); float d = p.y - r*(1.0 - p.x); float aa = fwidth(d); return cov * clamp(0.5 - d/aa, 0.0, 1.0); }
float maskDiamond(vec2 p, float cov){ float r = sqrt(cov) * 0.564; return step(abs(p.x - 0.49) + abs(p.y - 0.49), r); }
void main(){
float pixelSize = uPixelSize;
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / pixelSize);
vec2 pixelUV = fract(fragCoord / pixelSize);
float cellPixelSize = 8.0 * pixelSize;
vec2 cellId = floor(fragCoord / cellPixelSize);
vec2 cellCoord = cellId * cellPixelSize;
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float base = fbm2(uv, uTime * 0.05);
base = base * 0.5 - 0.65;
float feed = base + (uDensity - 0.5) * 0.3;
float speed = uRippleSpeed; float thickness = uRippleThickness;
const float dampT = 1.0; const float dampR = 10.0;
if (uEnableRipples == 1) {
for (int i = 0; i < MAX_CLICKS; ++i){
vec2 pos = uClickPos[i]; if (pos.x < 0.0) continue;
float cellPixelSize2 = 8.0 * pixelSize;
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize2 * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
float t = max(uTime - uClickTimes[i], 0.0);
float r = distance(uv, cuv);
float waveR = speed * t;
float ring = exp(-pow((r - waveR) / thickness, 2.0));
float atten = exp(-dampT * t) * exp(-dampR * r);
feed = max(feed, ring * atten * uRippleIntensity);
}
}
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float bw = step(0.5, feed + bayer);
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
float coverage = bw * jitterScale;
float M;
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
else M = coverage;
if (uEdgeFade > 0.0) {
vec2 norm = gl_FragCoord.xy / uResolution;
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
float fade = smoothstep(0.0, uEdgeFade, edge);
M *= fade;
}
vec3 color = uColor;
vec3 srgbColor = mix(color * 12.92, 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, color));
fragColor = vec4(srgbColor, M);
}`;
const MAX_CLICKS = 10;
interface PixelBlastProps {
variant?: 'square' | 'circle' | 'triangle' | 'diamond';
pixelSize?: number;
color?: string;
className?: string;
style?: React.CSSProperties;
antialias?: boolean;
patternScale?: number;
patternDensity?: number;
liquid?: boolean;
liquidStrength?: number;
liquidRadius?: number;
pixelSizeJitter?: number;
enableRipples?: boolean;
rippleIntensityScale?: number;
rippleThickness?: number;
rippleSpeed?: number;
liquidWobbleSpeed?: number;
autoPauseOffscreen?: boolean;
speed?: number;
transparent?: boolean;
edgeFade?: number;
noiseAmount?: number;
}
const PixelBlast = ({
variant = 'square',
pixelSize = 3,
color = '#B497CF',
className,
style,
antialias = true,
patternScale = 2,
patternDensity = 1,
liquid = false,
liquidStrength = 0.1,
liquidRadius = 1,
pixelSizeJitter = 0,
enableRipples = true,
rippleIntensityScale = 1,
rippleThickness = 0.1,
rippleSpeed = 0.3,
liquidWobbleSpeed = 4.5,
autoPauseOffscreen = true,
speed = 0.5,
transparent = true,
edgeFade = 0.5,
noiseAmount = 0,
}: PixelBlastProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const visibilityRef = useRef({ visible: true });
const speedRef = useRef(speed);
const threeRef = useRef<any>(null);
const prevConfigRef = useRef<any>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
speedRef.current = speed;
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
const cfg: Record<string, any> = { antialias, liquid, noiseAmount };
let mustReinit = false;
if (!threeRef.current) mustReinit = true;
else if (prevConfigRef.current) {
for (const k of needsReinitKeys)
if (prevConfigRef.current[k] !== cfg[k]) { mustReinit = true; break; }
}
if (mustReinit) {
if (threeRef.current) {
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
t.renderer.forceContextLoss();
if (t.renderer.domElement.parentElement === container)
container.removeChild(t.renderer.domElement);
threeRef.current = null;
}
const canvas = document.createElement('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: true, powerPreference: 'high-performance' });
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
container.appendChild(renderer.domElement);
if (transparent) renderer.setClearAlpha(0);
else renderer.setClearColor(0x000000, 1);
const uniforms: Record<string, any> = {
uResolution: { value: new THREE.Vector2(0, 0) },
uTime: { value: 0 },
uColor: { value: new THREE.Color(color) },
uClickPos: { value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1)) },
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
uScale: { value: patternScale },
uDensity: { value: patternDensity },
uPixelJitter: { value: pixelSizeJitter },
uEnableRipples: { value: enableRipples ? 1 : 0 },
uRippleSpeed: { value: rippleSpeed },
uRippleThickness: { value: rippleThickness },
uRippleIntensity: { value: rippleIntensityScale },
uEdgeFade: { value: edgeFade },
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SRC,
fragmentShader: FRAGMENT_SRC,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
glslVersion: THREE.GLSL3,
});
const quadGeom = new THREE.PlaneGeometry(2, 2);
const quad = new THREE.Mesh(quadGeom, material);
scene.add(quad);
const clock = new THREE.Clock();
const setSize = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
renderer.setSize(w, h, false);
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
if (threeRef.current?.composer)
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
};
setSize();
const ro = new ResizeObserver(setSize);
ro.observe(container);
const randomFloat = () => {
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
const u32 = new Uint32Array(1);
window.crypto.getRandomValues(u32);
return u32[0] / 0xffffffff;
}
return Math.random();
};
const timeOffset = randomFloat() * 1000;
let composer: EffectComposer | undefined;
let touch: ReturnType<typeof createTouchTexture> | undefined;
let liquidEffect: Effect | undefined;
if (liquid) {
touch = createTouchTexture();
touch.radiusScale = liquidRadius;
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
liquidEffect = createLiquidEffect(touch.texture, { strength: liquidStrength, freq: liquidWobbleSpeed });
const effectPass = new EffectPass(camera, liquidEffect);
effectPass.renderToScreen = true;
composer.addPass(renderPass);
composer.addPass(effectPass);
}
if (noiseAmount > 0) {
if (!composer) {
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
}
const noiseEffect = new Effect('NoiseEffect',
`uniform float uTime; uniform float uAmount;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453); }
void mainUv(inout vec2 uv){}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor){
float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0));
float g=(n-0.5)*uAmount;
outputColor=inputColor+vec4(vec3(g),0.0);
}`,
{ uniforms: new Map([['uTime', new THREE.Uniform(0)], ['uAmount', new THREE.Uniform(noiseAmount)]]) }
);
const noisePass = new EffectPass(camera, noiseEffect);
noisePass.renderToScreen = true;
if (composer && composer.passes.length > 0) composer.passes.forEach((p: any) => (p.renderToScreen = false));
composer.addPass(noisePass);
}
if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
const mapToPixels = (e: PointerEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
const scaleX = renderer.domElement.width / rect.width;
const scaleY = renderer.domElement.height / rect.height;
const fx = (e.clientX - rect.left) * scaleX;
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
return { fx, fy, w: renderer.domElement.width, h: renderer.domElement.height };
};
const onPointerDown = (e: PointerEvent) => {
const { fx, fy } = mapToPixels(e);
const ix = threeRef.current?.clickIx ?? 0;
uniforms.uClickPos.value[ix].set(fx, fy);
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
};
const onPointerMove = (e: PointerEvent) => {
if (!touch) return;
const { fx, fy, w, h } = mapToPixels(e);
touch.addTouch({ x: fx / w, y: fy / h });
};
renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true });
renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true });
let raf = 0;
const animate = () => {
if (autoPauseOffscreen && !visibilityRef.current.visible) {
raf = requestAnimationFrame(animate);
return;
}
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value;
if (composer) {
if (touch) touch.update();
composer.passes.forEach((p: any) => {
const effs = p.effects;
if (effs) effs.forEach((eff: any) => { const u = eff.uniforms?.get('uTime'); if (u) u.value = uniforms.uTime.value; });
});
composer.render();
} else renderer.render(scene, camera);
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
threeRef.current = { renderer, scene, camera, material, clock, clickIx: 0, uniforms, resizeObserver: ro, raf, quad, timeOffset, composer, touch, liquidEffect };
} else {
const t = threeRef.current;
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
t.uniforms.uColor.value.set(color);
t.uniforms.uScale.value = patternScale;
t.uniforms.uDensity.value = patternDensity;
t.uniforms.uPixelJitter.value = pixelSizeJitter;
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
t.uniforms.uRippleThickness.value = rippleThickness;
t.uniforms.uRippleSpeed.value = rippleSpeed;
t.uniforms.uEdgeFade.value = edgeFade;
if (transparent) t.renderer.setClearAlpha(0);
else t.renderer.setClearColor(0x000000, 1);
if (t.liquidEffect) {
const uFreq = t.liquidEffect.uniforms.get('uFreq');
if (uFreq) uFreq.value = liquidWobbleSpeed;
}
if (t.touch) t.touch.radiusScale = liquidRadius;
}
prevConfigRef.current = cfg;
return () => {
if (threeRef.current && mustReinit) return;
if (!threeRef.current) return;
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
t.renderer.forceContextLoss();
if (t.renderer.domElement.parentElement === container)
container.removeChild(t.renderer.domElement);
threeRef.current = null;
};
}, [
antialias, liquid, noiseAmount, pixelSize, patternScale, patternDensity,
enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed,
pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius,
liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed,
]);
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={style}
aria-label="PixelBlast interactive background"
/>
);
};
export default PixelBlast;

View File

@@ -1,8 +1,21 @@
import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react"; import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { LampTop } from "@/components/ui/lamp"; import { LampTop } from "@/components/ui/lamp";
import LightRays from "@/components/LightRays"; import LightRays from "@/components/LightRays";
import PixelBlast from "@/components/PixelBlast";
const ProblemSection = () => { const ProblemSection = () => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && resolvedTheme === "dark";
const isLight = mounted && resolvedTheme === "light";
const problems = [ const problems = [
{ {
icon: Calendar, icon: Calendar,
@@ -24,6 +37,23 @@ const ProblemSection = () => {
return ( return (
<section className="section-problem-solution py-24 md:py-32 relative overflow-hidden"> <section className="section-problem-solution py-24 md:py-32 relative overflow-hidden">
{/* PixelBlast animated background - nur im Light Mode */}
{isLight && (
<div className="absolute inset-0 z-0 w-full h-full">
<PixelBlast
variant="circle"
pixelSize={3}
color="#f43f5e"
patternScale={7.75}
patternDensity={0.7}
pixelSizeJitter={2}
enableRipples={false}
speed={0.3}
edgeFade={0.5}
transparent
/>
</div>
)}
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */} {/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
<div <div
className="problem-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0" className="problem-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0"
@@ -32,6 +62,8 @@ const ProblemSection = () => {
}} }}
aria-hidden aria-hidden
/> />
{/* LightRays - nur im Dark Mode */}
{isDark && (
<div className="absolute inset-0 w-full overflow-hidden z-0"> <div className="absolute inset-0 w-full overflow-hidden z-0">
<LightRays <LightRays
raysOrigin="top-center" raysOrigin="top-center"
@@ -48,6 +80,7 @@ const ProblemSection = () => {
saturation={2} saturation={2}
/> />
</div> </div>
)}
<LampTop /> <LampTop />
<div className="container mx-auto px-6 relative z-10"> <div className="container mx-auto px-6 relative z-10">
{/* Section Header */} {/* Section Header */}
@@ -66,7 +99,7 @@ const ProblemSection = () => {
{problems.map((problem, index) => ( {problems.map((problem, index) => (
<div <div
key={index} key={index}
className="problem-section-tint flex items-start gap-4 p-6 border border-border rounded-lg bg-card/50 hover:border-foreground/20 transition-colors" className="problem-section-tint flex items-start gap-4 p-6 rounded-2xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] shadow-[0_8px_32px_rgba(0,0,0,0.12)] hover:bg-white/[0.06] hover:border-white/[0.12] transition-all duration-300"
> >
<div className="w-10 h-10 rounded-full border border-destructive/30 bg-destructive/10 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 rounded-full border border-destructive/30 bg-destructive/10 flex items-center justify-center flex-shrink-0">
<problem.icon className="w-5 h-5 text-destructive" /> <problem.icon className="w-5 h-5 text-destructive" />

View File

@@ -1,4 +1,6 @@
import CountUp from "@/components/CountUp"; import CountUp from "@/components/CountUp";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import PixelBlast from "@/components/PixelBlast";
const Process = () => { const Process = () => {
const steps = [ const steps = [
@@ -25,14 +27,37 @@ const Process = () => {
]; ];
return ( return (
<section id="process" className="py-24 md:py-32 bg-secondary/20 relative"> <section id="process" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-secondary/20 relative">
<div className="container mx-auto px-6"> {/* PixelBlast animated background */}
<div className="absolute inset-0 z-0 w-full h-full">
<PixelBlast
variant="circle"
pixelSize={3}
color="#5b5b5b"
patternScale={7.75}
patternDensity={0.7}
pixelSizeJitter={2}
enableRipples={false}
rippleSpeed={0.4}
rippleThickness={0.12}
rippleIntensityScale={1.5}
liquid={false}
liquidStrength={0.12}
liquidRadius={1.2}
liquidWobbleSpeed={5}
speed={0.3}
edgeFade={0.5}
transparent
/>
</div>
{/* TextHoverEffect */}
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
<TextHoverEffect text="Ablauf" />
</div>
<div className="container mx-auto px-6 relative z-10">
{/* Section Header */} {/* Section Header */}
<div className="mb-16 md:mb-24"> <div className="mb-4">
<div className="label-tag mb-4">So arbeiten wir</div> <div className="label-tag mb-4">So arbeiten wir</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase">
Ablauf
</h2>
</div> </div>
<div className="max-w-4xl"> <div className="max-w-4xl">

View File

@@ -1,4 +1,7 @@
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import { useTheme } from "next-themes";
import BorderGlow from "@/components/BorderGlow";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
type Project = { type Project = {
title: string; title: string;
@@ -41,26 +44,36 @@ const projects: Project[] = [
]; ];
const ProjectShowcase = () => { const ProjectShowcase = () => {
const { resolvedTheme } = useTheme();
const cardBg = resolvedTheme === "dark" ? "hsl(0 0% 6%)" : "hsl(0 0% 96%)";
return ( return (
<section id="projects" className="py-24 md:py-32 bg-background relative"> <section id="projects" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-background relative">
<div className="container mx-auto px-6"> {/* TextHoverEffect */}
{/* Section Header */} <div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
<div className="mb-16 md:mb-24"> <TextHoverEffect text="Projekte" />
<div className="label-tag mb-4">Ausgewählte Arbeiten</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase">
Projekte
</h2>
</div> </div>
<div className="container mx-auto px-6">
{/* Projects Grid */} {/* Projects Grid */}
<div className="space-y-2"> <div className="space-y-2">
{projects.map((project, index) => ( {projects.map((project, index) => (
<a <BorderGlow
key={project.title} key={project.title}
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor={cardBg}
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<a
href={project.url} href={project.url}
target={project.url.startsWith("http") ? "_blank" : undefined} target={project.url.startsWith("http") ? "_blank" : undefined}
rel={project.url.startsWith("http") ? "noopener noreferrer" : undefined} rel={project.url.startsWith("http") ? "noopener noreferrer" : undefined}
className="group block project-card rounded-lg p-6 md:p-8" className="group block p-6 md:p-8"
style={{ animationDelay: `${index * 0.1}s` }} style={{ animationDelay: `${index * 0.1}s` }}
> >
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -84,6 +97,7 @@ const ProjectShowcase = () => {
</div> </div>
</div> </div>
</a> </a>
</BorderGlow>
))} ))}
</div> </div>
</div> </div>

View File

@@ -29,14 +29,11 @@ const Services = () => {
]; ];
return ( return (
<section id="services" className="py-24 md:py-32 bg-background relative"> <section id="services" className="pt-8 pb-8 md:pt-12 md:pb-12 bg-background relative">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
{/* Section Header */} {/* Section Header */}
<div className="mb-16 md:mb-24"> <div className="mb-4">
<div className="label-tag mb-4">Was wir tun</div> <div className="label-tag mb-4">Was wir tun</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase">
Leistungen
</h2>
</div> </div>
<div className="grid md:grid-cols-2 gap-px bg-border rounded-lg overflow-hidden"> <div className="grid md:grid-cols-2 gap-px bg-border rounded-lg overflow-hidden">

View File

@@ -33,6 +33,7 @@ uniform float uSpeed;
uniform float uScale; uniform float uScale;
uniform float uRotation; uniform float uRotation;
uniform float uNoiseIntensity; uniform float uNoiseIntensity;
uniform vec3 uColor2;
const float e = 2.71828182845904523536; const float e = 2.71828182845904523536;
@@ -63,7 +64,8 @@ void main() {
0.02 * tOffset) + 0.02 * tOffset) +
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset))); sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity; vec3 mixed = mix(uColor2, uColor, pattern);
vec4 col = vec4(mixed, 1.0) - rnd / 15.0 * uNoiseIntensity;
col.a = 1.0; col.a = 1.0;
gl_FragColor = col; gl_FragColor = col;
} }
@@ -74,6 +76,7 @@ type SilkPlaneProps = {
uSpeed: { value: number }; uSpeed: { value: number };
uScale: { value: number }; uScale: { value: number };
uNoiseIntensity: { value: number }; uNoiseIntensity: { value: number };
uColor2: { value: Color };
uColor: { value: Color }; uColor: { value: Color };
uRotation: { value: number }; uRotation: { value: number };
uTime: { value: number }; uTime: { value: number };
@@ -118,6 +121,7 @@ type SilkProps = {
speed?: number; speed?: number;
scale?: number; scale?: number;
color?: string; color?: string;
color2?: string;
noiseIntensity?: number; noiseIntensity?: number;
rotation?: number; rotation?: number;
}; };
@@ -126,6 +130,7 @@ const Silk = ({
speed = 5, speed = 5,
scale = 1, scale = 1,
color = "#7B7481", color = "#7B7481",
color2 = "#000000",
noiseIntensity = 1.5, noiseIntensity = 1.5,
rotation = 0, rotation = 0,
}: SilkProps) => { }: SilkProps) => {
@@ -136,11 +141,12 @@ const Silk = ({
uSpeed: { value: speed }, uSpeed: { value: speed },
uScale: { value: scale }, uScale: { value: scale },
uNoiseIntensity: { value: noiseIntensity }, uNoiseIntensity: { value: noiseIntensity },
uColor2: { value: new Color(...hexToNormalizedRGB(color2)) },
uColor: { value: new Color(...hexToNormalizedRGB(color)) }, uColor: { value: new Color(...hexToNormalizedRGB(color)) },
uRotation: { value: rotation }, uRotation: { value: rotation },
uTime: { value: 0 }, uTime: { value: 0 },
}), }),
[speed, scale, noiseIntensity, color, rotation] [speed, scale, noiseIntensity, color, color2, rotation]
); );
return ( return (

View File

@@ -1,10 +1,23 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowRight, CheckCircle2 } from "lucide-react"; import { ArrowRight, CheckCircle2 } from "lucide-react";
import { LampTop } from "@/components/ui/lamp"; import { LampTop } from "@/components/ui/lamp";
import LightRays from "@/components/LightRays"; import LightRays from "@/components/LightRays";
import PixelBlast from "@/components/PixelBlast";
const SolutionSection = () => { const SolutionSection = () => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && resolvedTheme === "dark";
const isLight = mounted && resolvedTheme === "light";
const benefits = [ const benefits = [
"Alle Prozesse greifen ineinander.", "Alle Prozesse greifen ineinander.",
"Informationen fließen automatisch.", "Informationen fließen automatisch.",
@@ -13,6 +26,23 @@ const SolutionSection = () => {
return ( return (
<section className="section-problem-solution py-24 md:py-32 relative overflow-hidden"> <section className="section-problem-solution py-24 md:py-32 relative overflow-hidden">
{/* PixelBlast animated background - nur im Light Mode */}
{isLight && (
<div className="absolute inset-0 z-0 w-full h-full">
<PixelBlast
variant="circle"
pixelSize={3}
color="#06b6d4"
patternScale={7.75}
patternDensity={0.7}
pixelSizeJitter={2}
enableRipples={false}
speed={0.3}
edgeFade={0.5}
transparent
/>
</div>
)}
{/* Hintergrundbild mittig, auf Handy maximale Breite */} {/* Hintergrundbild mittig, auf Handy maximale Breite */}
<div <div
className="solution-section-bg absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0" className="solution-section-bg absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0"
@@ -21,6 +51,8 @@ const SolutionSection = () => {
}} }}
aria-hidden aria-hidden
/> />
{/* LightRays - nur im Dark Mode */}
{isDark && (
<div className="absolute inset-0 w-full overflow-hidden z-0"> <div className="absolute inset-0 w-full overflow-hidden z-0">
<LightRays <LightRays
raysOrigin="top-center" raysOrigin="top-center"
@@ -37,6 +69,7 @@ const SolutionSection = () => {
saturation={2} saturation={2}
/> />
</div> </div>
)}
<LampTop lineClassName="bg-cyan-400" /> <LampTop lineClassName="bg-cyan-400" />
<div className="container mx-auto px-6 relative z-10"> <div className="container mx-auto px-6 relative z-10">
<div className="grid lg:grid-cols-2 gap-16 items-center"> <div className="grid lg:grid-cols-2 gap-16 items-center">
@@ -74,7 +107,7 @@ const SolutionSection = () => {
{/* Right Content - Visual Element */} {/* Right Content - Visual Element */}
<div className="relative"> <div className="relative">
<div className="solution-section-tint aspect-square bg-secondary/50 rounded-2xl border border-border p-8 md:p-12 flex flex-col justify-center"> <div className="solution-section-tint aspect-square rounded-2xl bg-white/[0.04] backdrop-blur-xl border border-white/[0.08] shadow-[0_8px_32px_rgba(0,0,0,0.12)] p-8 md:p-12 flex flex-col justify-center">
<div className="space-y-6"> <div className="space-y-6">
<div className="text-sm uppercase tracking-wider text-muted-foreground">Das Ergebnis</div> <div className="text-sm uppercase tracking-wider text-muted-foreground">Das Ergebnis</div>
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground uppercase tracking-tight"> <h3 className="text-2xl md:text-3xl font-display font-medium text-foreground uppercase tracking-tight">

View File

@@ -0,0 +1,20 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps as NextThemeProviderProps } from "next-themes";
export interface ThemeProviderProps extends Omit<NextThemeProviderProps, "children"> {
children: React.ReactNode;
}
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem={true}
storageKey="theme"
{...props}
>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { Sun, Moon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface ThemeToggleProps {
className?: string;
}
const themeLabels: Record<string, string> = {
light: "Hell",
dark: "Dunkel",
system: "System",
};
export function ThemeToggle({ className }: ThemeToggleProps) {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const currentLabel = themeLabels[theme ?? "system"] ?? "System";
const icon = !mounted ? (
<Monitor className="h-4 w-4" />
) : theme === "system" ? (
<Monitor className="h-4 w-4" />
) : resolvedTheme === "dark" ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className}
aria-label={`Theme wechseln, aktuell: ${currentLabel}`}
>
{icon}
<span className="sr-only">Theme wechseln</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
Hell
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
Dunkel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,4 +1,6 @@
import { Users, Cog, MessageSquare, Target, BarChart3, Layers } from "lucide-react"; import { Users, Cog, MessageSquare, Target, BarChart3, Layers } from "lucide-react";
import BorderGlow from "@/components/BorderGlow";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
const Values = () => { const Values = () => {
const features = [ const features = [
@@ -35,10 +37,14 @@ const Values = () => {
]; ];
return ( return (
<section id="features" className="py-24 md:py-32 bg-background relative overflow-hidden"> <section id="features" className="pt-8 pb-0 md:pt-12 md:pb-0 bg-background relative overflow-hidden">
{/* TextHoverEffect */}
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
<TextHoverEffect text="Leistungen" />
</div>
{/* Hintergrundbild: auf Handy maximale Breite */} {/* Hintergrundbild: auf Handy maximale Breite */}
<div <div
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3]" className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
style={{ style={{
backgroundImage: "url(/backgroud_effect.png)", backgroundImage: "url(/backgroud_effect.png)",
}} }}
@@ -55,9 +61,19 @@ const Values = () => {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((feature, index) => ( {features.map((feature, index) => (
<div <BorderGlow
key={feature.title} key={feature.title}
className="group p-6 border border-border rounded-lg bg-card/50 hover:border-foreground/20 transition-colors" edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 6%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div
className="group p-6"
style={{ animationDelay: `${index * 0.1}s` }} style={{ animationDelay: `${index * 0.1}s` }}
> >
<div className="w-12 h-12 rounded-full border border-border flex items-center justify-center mb-6 group-hover:border-foreground/30 transition-colors"> <div className="w-12 h-12 rounded-full border border-border flex items-center justify-center mb-6 group-hover:border-foreground/30 transition-colors">
@@ -70,6 +86,7 @@ const Values = () => {
{feature.description} {feature.description}
</p> </p>
</div> </div>
</BorderGlow>
))} ))}
</div> </div>
</div> </div>

View File

@@ -18,7 +18,7 @@ export const LampTop = ({
return ( return (
<div <div
className={cn( className={cn(
"absolute top-0 left-0 right-0 w-full min-h-0 pointer-events-none z-50 flex items-start justify-center", "absolute top-0 left-0 right-0 w-full min-h-0 pointer-events-none z-10 flex items-start justify-center",
className className
)} )}
> >

View File

@@ -9,6 +9,7 @@ import {
useMotionValueEvent, useMotionValueEvent,
} from "motion/react"; } from "motion/react";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
interface NavbarProps { interface NavbarProps {
children: React.ReactNode; children: React.ReactNode;
@@ -72,8 +73,8 @@ export const NavBody = ({ children, className, visible }: NavBodyProps) => {
}} }}
className={cn( className={cn(
"relative z-[60] mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full bg-transparent px-4 py-2 lg:flex", "relative z-[60] mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full bg-transparent px-4 py-2 lg:flex",
"text-white [&_a]:text-white [&_a:hover]:text-white/90 [&_.navbar-actions_a]:!text-black", "text-black dark:text-white [&_a]:text-black dark:[&_a]:text-white [&_a:hover]:text-black/70 dark:[&_a:hover]:text-white/90 [&_.navbar-actions_a]:!text-black",
visible && "bg-black/90", visible && "!text-white dark:!text-white [&_a]:!text-white dark:[&_a]:!text-white [&_a:hover]:!text-white/90 bg-black/90",
className className
)} )}
> >
@@ -108,14 +109,16 @@ export const NavItems = ({
className className
)} )}
> >
{items.map((item, idx) => ( {items.map((item, idx) => {
<a const isRoute = item.link.startsWith("/");
onMouseEnter={() => setHovered(idx)} const commonProps = {
onClick={onItemClick} onMouseEnter: () => setHovered(idx),
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300" onClick: onItemClick,
key={`link-${idx}`} className: "relative px-4 py-2 text-neutral-900 dark:text-neutral-300",
href={item.link} key: `link-${idx}`,
> };
const inner = (
<>
{hovered === idx && ( {hovered === idx && (
<motion.div <motion.div
layoutId="hovered" layoutId="hovered"
@@ -123,8 +126,14 @@ export const NavItems = ({
/> />
)} )}
<span className="relative z-20">{item.name}</span> <span className="relative z-20">{item.name}</span>
</a> </>
))} );
return isRoute ? (
<RouterLink to={item.link} {...commonProps}>{inner}</RouterLink>
) : (
<a href={item.link} {...commonProps}>{inner}</a>
);
})}
</motion.div> </motion.div>
); );
}; };
@@ -160,8 +169,8 @@ export const MobileNav = ({
}} }}
className={cn( className={cn(
"relative z-50 mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between bg-transparent px-0 py-2 lg:hidden", "relative z-50 mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between bg-transparent px-0 py-2 lg:hidden",
"[&>div:first-child]:text-white [&>div:first-child_a]:text-white [&>div:first-child_svg]:text-white", "[&>div:first-child]:text-black dark:[&>div:first-child]:text-white [&>div:first-child_a]:text-black dark:[&>div:first-child_a]:text-white [&>div:first-child_svg]:text-black dark:[&>div:first-child_svg]:text-white",
visible && "bg-black/90", visible && "[&>div:first-child]:!text-white dark:[&>div:first-child]:!text-white [&>div:first-child_a]:!text-white [&>div:first-child_svg]:!text-white bg-black/90",
className className
)} )}
> >

View File

@@ -0,0 +1,120 @@
"use client";
import { useRef, useState, useCallback, useId } from "react";
import { motion } from "motion/react";
export const TextHoverEffect = ({
text,
}: {
text: string;
duration?: number;
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const [hovered, setHovered] = useState(false);
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
const id = useId();
const gradientId = `textGradient-${id}`;
const revealMaskId = `revealMask-${id}`;
const textMaskId = `textMask-${id}`;
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
// Map screen coords to viewBox coords (0 0 300 60)
const x = ((e.clientX - rect.left) / rect.width) * 300;
const y = ((e.clientY - rect.top) / rect.height) * 60;
setCursorPos({ x, y });
},
[]
);
return (
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 300 60"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
className="select-none"
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#eab308" />
<stop offset="25%" stopColor="#ef4444" />
<stop offset="50%" stopColor="#3b82f6" />
<stop offset="75%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#8b5cf6" />
</linearGradient>
<radialGradient
id={revealMaskId}
gradientUnits="userSpaceOnUse"
cx={cursorPos.x}
cy={cursorPos.y}
r="80"
>
<stop offset="0%" stopColor="white" />
<stop offset="100%" stopColor="black" />
</radialGradient>
<mask id={textMaskId}>
<rect
x="0"
y="0"
width="300"
height="60"
fill={hovered ? `url(#${revealMaskId})` : "black"}
/>
</mask>
</defs>
{/* Faint outline always visible */}
<text
x="150"
y="30"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ opacity: 0.15, fontSize: "2rem" }}
>
{text}
</text>
{/* Animated stroke draw */}
<motion.text
x="150"
y="30"
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ fontSize: "2rem" }}
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
animate={{ strokeDashoffset: 0, strokeDasharray: 1000 }}
transition={{ duration: 4, ease: "easeInOut" }}
>
{text}
</motion.text>
{/* Colored gradient revealed on hover */}
<text
x="150"
y="30"
textAnchor="middle"
dominantBaseline="middle"
stroke={`url(#${gradientId})`}
strokeWidth="0.3"
mask={`url(#${textMaskId})`}
className="font-display font-bold fill-transparent"
style={{ fontSize: "2rem" }}
>
{text}
</text>
</svg>
);
};

View File

@@ -10,11 +10,79 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* webklar Design System - Muradov Inspired Minimal Dark Theme */ /* webklar Design System - Light/Dark Theme Support */
@layer base { @layer base {
:root { :root {
/* Ultra Minimal Deep Black Theme - Muradov Inspired */ /* Light Theme */
--background: 0 0% 100%;
--foreground: 0 0% 9%;
--card: 0 0% 98%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 98%;
--popover-foreground: 0 0% 9%;
/* Primary - Cyan-Blau */
--primary: 198 93% 42%;
--primary-foreground: 0 0% 98%;
/* Secondary - Helles Grau */
--secondary: 0 0% 94%;
--secondary-foreground: 0 0% 9%;
/* Muted */
--muted: 0 0% 94%;
--muted-foreground: 0 0% 40%;
/* Accent */
--accent: 0 0% 94%;
--accent-foreground: 0 0% 9%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 88%;
--input: 0 0% 88%;
--ring: 198 93% 42%;
--radius: 0.5rem;
/* Shadows */
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--sidebar-background: 0 0% 97%;
--sidebar-foreground: 0 0% 9%;
--sidebar-primary: 198 93% 42%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 94%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 88%;
--sidebar-ring: 198 93% 42%;
--chart-1: 198 93% 42%;
--chart-2: 213 93% 50%;
--chart-3: 0 0% 45%;
--chart-4: 0 0% 35%;
--chart-5: 0 0% 25%;
--sidebar: 0 0% 97%;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
--font-display: 'Space Grotesk', system-ui, sans-serif;
--spacing: 0.25rem;
--font-serif: 'Lora', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
}
.dark {
/* Dark Theme original :root dark color scheme */
--background: 0 0% 0%; --background: 0 0% 0%;
--foreground: 0 0% 92%; --foreground: 0 0% 92%;
@@ -68,61 +136,10 @@
--chart-4: 215 16% 46%; --chart-4: 215 16% 46%;
--chart-5: 215 19% 34%; --chart-5: 215 19% 34%;
--sidebar: 210 40% 98%; --sidebar: 210 40% 98%;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
--font-display: 'Space Grotesk', system-ui, sans-serif;
--spacing: 0.25rem;
--font-serif: 'Lora', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 217 32% 17%;
--card-foreground: 210 40% 98%;
--popover: 215 24% 26%;
--popover-foreground: 210 40% 98%;
--primary: 198 93% 59%;
--primary-foreground: 204 80% 15%;
--secondary: 212 26% 83%;
--secondary-foreground: 228 84% 4%;
--muted: 215 16% 46%;
--muted-foreground: 210 40% 98%;
--accent: 228 84% 4%;
--accent-foreground: 215 20% 65%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 85% 97%;
--border: 215 19% 34%;
--input: 215 19% 34%;
--ring: 198 93% 59%;
--chart-1: 199 95% 73%;
--chart-2: 211 96% 78%;
--chart-3: 215 20% 65%;
--chart-4: 215 16% 46%;
--chart-5: 215 19% 34%;
--sidebar: 217 32% 17%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 198 93% 59%;
--sidebar-primary-foreground: 204 80% 15%;
--sidebar-accent: 215 20% 65%;
--sidebar-accent-foreground: 228 84% 4%;
--sidebar-border: 215 19% 34%;
--sidebar-ring: 198 93% 59%;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--radius: 0rem;
} }
} }
@@ -216,11 +233,11 @@
border-radius: inherit; border-radius: inherit;
} }
/* Minimal glass nav */ /* Minimal glass nav theme-aware */
.glass-nav { .glass-nav {
@apply backdrop-blur-xl border-b; @apply backdrop-blur-xl border-b;
background: hsl(0 0% 3% / 0.9); background: hsl(var(--background) / 0.9);
border-color: hsl(0 0% 15% / 0.5); border-color: hsl(var(--border) / 0.5);
} }
/* Card minimal */ /* Card minimal */
@@ -230,12 +247,12 @@
border: 1px solid hsl(var(--border)); border: 1px solid hsl(var(--border));
} }
.card-minimal:hover { .card-minimal:hover {
border-color: hsl(0 0% 25%); border-color: hsl(var(--muted-foreground));
} }
/* Text gradient - subtle */ /* Text gradient - theme-aware */
.text-gradient { .text-gradient {
background: linear-gradient(135deg, hsl(0 0% 100%) 0%, hsl(0 0% 70%) 100%); background: linear-gradient(135deg, hsl(var(--foreground)) 0%, hsl(var(--muted-foreground)) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
@@ -248,7 +265,7 @@
.link-underline::after { .link-underline::after {
content: ''; content: '';
@apply absolute bottom-0 left-0 w-full h-px scale-x-0 origin-right transition-transform duration-300; @apply absolute bottom-0 left-0 w-full h-px scale-x-0 origin-right transition-transform duration-300;
background: hsl(0 0% 98%); background: hsl(var(--foreground));
} }
.link-underline:hover::after { .link-underline:hover::after {
@apply scale-x-100 origin-left; @apply scale-x-100 origin-left;
@@ -268,37 +285,37 @@
animation: marquee 30s linear infinite; animation: marquee 30s linear infinite;
} }
/* Minimal project card */ /* Minimal project card theme-aware */
.project-card { .project-card {
@apply transition-all duration-500 overflow-hidden; @apply transition-all duration-500 overflow-hidden;
background: hsl(0 0% 6%); background: hsl(var(--card));
border: 1px solid hsl(0 0% 12%); border: 1px solid hsl(var(--border));
} }
.project-card:hover { .project-card:hover {
border-color: hsl(0 0% 25%); border-color: hsl(var(--muted-foreground));
transform: translateY(-4px); transform: translateY(-4px);
} }
/* Minimal button */ /* Minimal button theme-aware */
.btn-minimal { .btn-minimal {
@apply relative transition-all duration-300; @apply relative transition-all duration-300;
background: hsl(0 0% 98%); background: hsl(var(--foreground));
color: hsl(0 0% 3%); color: hsl(var(--background));
} }
.btn-minimal:hover { .btn-minimal:hover {
background: hsl(0 0% 85%); background: hsl(var(--muted-foreground));
} }
/* Outline button */ /* Outline button theme-aware */
.btn-outline { .btn-outline {
@apply relative transition-all duration-300 border; @apply relative transition-all duration-300 border;
background: transparent; background: transparent;
color: hsl(0 0% 98%); color: hsl(var(--foreground));
border-color: hsl(0 0% 25%); border-color: hsl(var(--muted-foreground));
} }
.btn-outline:hover { .btn-outline:hover {
border-color: hsl(0 0% 50%); border-color: hsl(var(--foreground));
background: hsl(0 0% 10%); background: hsl(var(--accent));
} }
/* Custom CTA button (System-Demo) */ /* Custom CTA button (System-Demo) */
@@ -764,41 +781,33 @@
line-height: 1; line-height: 1;
} }
/* Count-up with gradient */ /* Count-up with gradient theme-aware */
.count-up-text { .count-up-text {
background: linear-gradient(135deg, hsl(0 0% 92%) 0%, hsl(0 0% 70%) 50%, hsl(0 0% 92%) 100%); background: linear-gradient(135deg, hsl(var(--foreground)) 0%, hsl(var(--muted-foreground)) 50%, hsl(var(--foreground)) 100%);
background-size: 200% auto; background-size: 200% auto;
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.dark .count-up-text { /* Grid line decoration theme-aware */
background: linear-gradient(135deg, hsl(0 0% 98%) 0%, hsl(0 0% 75%) 50%, hsl(0 0% 98%) 100%);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Grid line decoration */
.grid-lines { .grid-lines {
background-image: background-image:
linear-gradient(hsl(0 0% 12%) 1px, transparent 1px), linear-gradient(hsl(var(--border)) 1px, transparent 1px),
linear-gradient(90deg, hsl(0 0% 12%) 1px, transparent 1px); linear-gradient(90deg, hsl(var(--border)) 1px, transparent 1px);
background-size: 80px 80px; background-size: 80px 80px;
} }
/* Horizontal divider */ /* Horizontal divider theme-aware */
.divider { .divider {
@apply w-full h-px; @apply w-full h-px;
background: linear-gradient(90deg, transparent 0%, hsl(0 0% 20%) 50%, transparent 100%); background: linear-gradient(90deg, transparent 0%, hsl(var(--border)) 50%, transparent 100%);
} }
/* Label/Tag */ /* Label/Tag theme-aware */
.label-tag { .label-tag {
@apply text-xs uppercase tracking-widest font-medium; @apply text-xs uppercase tracking-widest font-medium;
color: hsl(0 0% 50%); color: hsl(var(--muted-foreground));
} }
} }

183
src/pages/About.tsx Normal file
View File

@@ -0,0 +1,183 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-react";
import CountUp from "@/components/CountUp";
import BorderGlow from "@/components/BorderGlow";
const About = () => {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">
Webklar
</span>
</Link>
<Link to="/">
<Button
variant="ghost"
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
</Link>
</div>
</div>
</header>
{/* Main Content */}
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-4xl mx-auto text-center">
<div className="label-tag mb-4">Die Realität</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
Mehr Arbeit darf nicht die einzige Antwort auf Wachstum sein.
</h1>
<div className="space-y-8 text-lg md:text-xl text-muted-foreground leading-relaxed">
<p>
Neue Kunden sollten Ihr Unternehmen nicht ins Chaos stürzen.
</p>
<p>
Wenn mehr Umsatz automatisch mehr Stress bedeutet, liegt das
Problem nicht an Ihrem Markt {" "}
<span className="text-foreground font-medium">
sondern an fehlenden Systemen.
</span>
</p>
<div className="pt-8 border-t border-border">
<p className="text-2xl md:text-3xl text-foreground font-display font-medium uppercase tracking-tight">
Erfolgreiche Unternehmen skalieren Prozesse.
</p>
<p className="text-2xl md:text-3xl text-muted-foreground font-display font-medium uppercase tracking-tight mt-2">
Nicht Arbeitsstunden.
</p>
</div>
<p className="text-foreground font-medium pt-4">
Genau dort setzen wir an.
</p>
</div>
{/* Back / Contact */}
<div className="mt-16 pt-12 border-t border-border flex flex-wrap justify-center gap-4">
<Link to="/">
<Button variant="outline" className="rounded-full">
Zur Startseite
</Button>
</Link>
<Link to="/kontakt">
<Button className="btn-minimal rounded-full">
Kontakt
</Button>
</Link>
</div>
</div>
</div>
{/* Der Unterschied */}
<div className="mt-24 py-24 md:py-32 bg-secondary/20 relative">
<div className="container mx-auto px-6">
<div className="max-w-4xl mx-auto text-center">
<div className="label-tag mb-4">Der Unterschied</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
Warum Unternehmen zu uns wechseln.
</h2>
<div className="grid md:grid-cols-3 gap-8 mb-12">
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-6">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={1} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Alles aus einer Hand
</h3>
<p className="text-muted-foreground text-sm">
Keine 10 verschiedenen Anbieter. Ein Partner für Ihre gesamte digitale Infrastruktur.
</p>
</div>
</BorderGlow>
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-6">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={2} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Systeme statt Inseln
</h3>
<p className="text-muted-foreground text-sm">
Wir verbinden Ihre Tools zu einem durchdachten Gesamtsystem.
</p>
</div>
</BorderGlow>
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-6">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={3} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Langfristige Partnerschaft
</h3>
<p className="text-muted-foreground text-sm">
Wir begleiten Sie nicht nur beim Launch, sondern beim Wachstum.
</p>
</div>
</BorderGlow>
</div>
<Link to="/kontakt">
<Button
size="lg"
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group"
>
Kostenlose Potenzialanalyse sichern
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
);
};
export default About;

View File

@@ -2,10 +2,8 @@ import Header from "@/components/Header";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import Partners from "@/components/Partners"; import Partners from "@/components/Partners";
import ProblemSection from "@/components/ProblemSection"; import ProblemSection from "@/components/ProblemSection";
import AgitationSection from "@/components/AgitationSection";
import SolutionSection from "@/components/SolutionSection"; import SolutionSection from "@/components/SolutionSection";
import Values from "@/components/Values"; import Values from "@/components/Values";
import DifferentiationSection from "@/components/DifferentiationSection";
import Services from "@/components/Services"; import Services from "@/components/Services";
import ProjectShowcase from "@/components/ProjectShowcase"; import ProjectShowcase from "@/components/ProjectShowcase";
import Process from "@/components/Process"; import Process from "@/components/Process";
@@ -19,10 +17,8 @@ const Index = () => {
<Hero /> <Hero />
<Partners /> <Partners />
<ProblemSection /> <ProblemSection />
<AgitationSection />
<SolutionSection /> <SolutionSection />
<Values /> <Values />
<DifferentiationSection />
<Services /> <Services />
<ProjectShowcase /> <ProjectShowcase />
<Process /> <Process />